Carrot Market
- user_id: 기본키
- img_url: 프로필 사진 링크는 null일 수 있다
- temp: 매너 온도로, 36.5 를 디폴트 값으로 가진다
- town_id: Town 테이블과 1:1로 대응되기 때문에 외래키로 설정해주었다
- town_id: 기본키
- town_x, town_y: 지역의 위도와 경도를 나타내서 위치를 알 수 있다
- post_id: 기본키
- status: enum으로 설정해서 판매중 or 예약중 or 거래완료 중 하나의 값을 가질 수 있다
- category_id: 물건의 분류를 나타내며 PostCategory 테이블과 1:1로 대응되기에 외래키 설정해주었다
- user_id: User 테이블과 ManyToOne 관계이며 해당 테이블의 기본키를 외래키로 가진다
- img_pk: 기본키
- post_id: Post 테이블과 ManyToOne 관계이며 해당 테이블의 기본키를 외래키로 가진다
- category_id: 기본키
- chatroom_id: 기본키
- chatroom_check_status: 0(읽음), 1(읽지않음) 두 가지 상태로 나타내진다
- post_id: Post 테이블과 ManyToOne 관계이며 해당 테이블의 기본키를 외래키로 가진다
- seller_id, buyer_id: User 테이블 참조하는 외래키
- msg_id: 기본키
- msg_check_status: 0(읽음), 1(읽지않음) 두 가지 상태로 나타내진다
- chatroom_id: ChatRoom 테이블과 ManyToOne 관계이며 해당 테이블의 기본키를 외래키로 가진다
- from_id: User 테이블 참조하는 외래키
- review_pk: 기본키
- preference: 거래 후기를 enum 으로 별로에요 or 좋아요 or 최고에요 중 하나로 남길 수 있으며 이는 매너온도에 반영된다
- reviewer_id, reviewee_id: User 테이블 참조하는 외래키, 리뷰를 남긴 사람과 리뷰를 받는 사람의 id
- post_id: Post 테이블과 ManyToOne 관계이며 해당 테이블의 기본키를 외래키로 가진다
@Test
@DisplayName("유저 findById 작동 테스트")
public void findbyUserid() {
//given
Town townA = Town.builder()
.town_name("경기도 수원시 영통구")
.town_x(33.8)
.town_y(12.3)
.build();
Town townB = Town.builder()
.town_name("서울시 서대문구 아현동")
.town_x(100.2)
.town_y(15.0)
.build();
Town townC = Town.builder()
.town_name("경기도 구리시 교문동")
.town_x(130.0)
.town_y(4.0)
.build();
townRepository.save(townA);
townRepository.save(townB);
townRepository.save(townC);
User userA = User.builder()
.img_url("a.jpg")
.user_name("김수한")
.user_nick("수수")
.pwd("1234")
.phone("01022223333")
.town(townA)
.build();
User userB = User.builder()
.img_url("b.jpg")
.user_name("철수한")
.user_nick("척척")
.pwd("2222")
.phone("01033334444")
.town(townB)
.build();
User userC = User.builder()
.img_url("c.jpg")
.user_name("한석봉")
.user_nick("떡떡")
.pwd("3333")
.phone("01055554444")
.town(townC)
.build();
User savedUserA = userRepository.save(userA);
User savedUserB = userRepository.save(userB);
User savedUserC = userRepository.save(userC);
// then
User findUserA = userRepository.findById(userA.getUser_id()).get();
User findUserB = userRepository.findById(userB.getUser_id()).get();
User findUserC = userRepository.findById(userC.getUser_id()).get();
//when
Assertions.assertThat(savedUserA).isSameAs(findUserA);
Assertions.assertThat(savedUserB).isSameAs(findUserB);
Assertions.assertThat(savedUserC).isSameAs(findUserC);
}
}User repository의 findById() 메소드가 잘 작동하는지 알기 위하여 먼저 외래키로 연결된 Town 객체를 3개 생성 후 save() 해주었고 이를 이용하여 User 객체도 3개 생성 후 save() 해주었다. 이후 저장한 객체가 findById()로 찾은 객체와 같은지 테스트해주었다.
Hibernate:
insert
into
town
(town_name,town_x,town_y)
values
(?,?,?)
Hibernate:
insert
into
town
(town_name,town_x,town_y)
values
(?,?,?)
Hibernate:
insert
into
town
(town_name,town_x,town_y)
values
(?,?,?)
Hibernate:
insert
into
user
(created_at,email,img_url,phone,pwd,temp,town_id,updated_at,user_name,user_nick)
values
(?,?,?,?,?,?,?,?,?,?)
Hibernate:
insert
into
user
(created_at,email,img_url,phone,pwd,temp,town_id,updated_at,user_name,user_nick)
values
(?,?,?,?,?,?,?,?,?,?)
Hibernate:
insert
into
user
(created_at,email,img_url,phone,pwd,temp,town_id,updated_at,user_name,user_nick)
values
(?,?,?,?,?,?,?,?,?,?)
> Task :test그랬더니 이처럼 insert 구문이 잘 작동한 것을 볼 수 있다.
test가 잘 안돌아가서 왜 그런가 했더니 application.yml 의 ddl-auto: create 를 안 해줘서 그런 거였다. 이번 과제를 하면서 배운 것이 정말 많았고 효율적인 도메인 설계에 대해 고민해보게 되었다. 시간관계상 or 미처 생각하지 못해서 구현하지 못한 것들이 좀 있어서 아쉽다. 다음에 좀 더 공부하고 리팩토링 해봐야겠다.
- BaseEntity 활용하기
- @NoArgsConstructor @AllArgsConstructor 사용 상황 (나는 일단 둘 다 넣었다 😅)
- @Builder 사용법 (다른 분들 코드 보니까 어떤 사람은 전체 class에 붙이고 어떤 사람은 생성자 만든 뒤 거기 위에 붙였다. 차이가 뭘까?!)
- notNull의 표기법 (@NotNull vs @Column(nullable = false) 둘 중에 뭐 써야되는건지 헷갈려서 찾아보니까 @NotNull 쓰는 게 좋다고는 하는데 곧 사라질 어노테이션이라고 경고떠서 뭐지 싶었다)
파이팅 🤣
POST 모델 선택해서 api 만들기
- BaseEntity 활용하기
- JAVA CamelCase, SQL snake_case 적용
- erd SQL TYPE 에 맞춰서 수정 & 구조 변경
- PostRequestDto
@NoArgsConstructor
@Getter
public class PostRequestDto {
private String title;
private Long price;
private String content;
private PostCategory category;
private User user;
@Builder
public PostRequestDto(String title, Long price, String content, PostCategory category, User user) {
this.title = title;
this.price = price;
this.content = content;
this.category = category;
this.user = user;
}
public Post toEntity() {
return Post.builder()
.title(title)
.price(price)
.content(content)
.status(PostStatus.SALE)
.category(category)
.user(user)
.build();
}
}status에 enum으로 설정한 SALE("판매중")를 기본값으로 넣어주었고, 아직 로그인 기능을 구현 못해서 일단 지금은 user도 받아올 수 있게 해주었다.
- PostResponseDto
@Getter
@NoArgsConstructor
public class PostResponseDto {
private Long id;
private String title;
private Long price;
private String content;
private PostStatus status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String categoryName;
private String userNick;
private List<PostImg> projectImages = new ArrayList<>();
@Builder // Entity to Dto
public PostResponseDto(Long id, String title, Long price, String content, PostStatus status, LocalDateTime createdAt, LocalDateTime updatedAt, String categoryName, String userNick) {
this.id = id;
this.title = title;
this.price = price;
this.content = content;
this.status = status;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
this.categoryName = categoryName;
this.userNick = userNick;
// this.projectImages = projectImages;
}
public static PostResponseDto from(Post post){
return PostResponseDto.builder()
.id(post.getId())
.title(post.getTitle())
.price(post.getPrice())
.content(post.getContent())
.status(post.getStatus())
.createdAt(post.getCreatedAt())
.updatedAt(post.getUpdatedAt())
.categoryName(post.getCategory().getName())
.userNick(post.getUser().getNick())
// .projectImages(post.getProjectImages())
.build();
}
}post_img 테이블에에 아직 데이터를 안 넣어둬서 지금은 무시해주었고, 카테고리 이름과 유저의 nickname을 출력해준다.
- PostService
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
@Transactional
public void createPost(PostRequestDto request) {
Post post = request.toEntity();
postRepository.save(post);
}
@Transactional
public List<PostResponseDto> getAllPosts() {
List<Post> postList = postRepository.findAll();
return postList.stream().map(post -> PostResponseDto.from(post)).toList();
}
@Transactional
public PostResponseDto getPost(Long id) {
return PostResponseDto.from(postRepository.findById(id)
.orElseThrow(() -> new PostNotFoundException()));
}
@Transactional
public void deletePost(Long id) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new PostNotFoundException());
postRepository.delete(post);
}
}- PostController
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping(value = "danggun/posts")
public class PostController {
private final PostService postService;
//@RequiredArgsConstructor를 사용했기 때문에 의존성이 자동 주입된다
@PostMapping
public ResponseEntity<Long> createPost(@RequestBody @Valid PostRequestDto request) {
log.info("상품 게시글 생성하기");
postService.createPost(request);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
@GetMapping
public ResponseEntity<List<PostResponseDto>> getAllPosts() {
log.info("모든 상품 게시글 조회하기");
List<PostResponseDto> PostResponseList = postService.getAllPosts();
return ResponseEntity.status(HttpStatus.OK).body(PostResponseList);
}
@GetMapping("/{id}")
public ResponseEntity<PostResponseDto> getPost(@PathVariable Long id) {
log.info("상품 게시글 상세 조회하기");
PostResponseDto postResponse = postService.getPost(id);
return ResponseEntity.status(HttpStatus.OK).body(postResponse);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deletePost(@PathVariable Long id) {
log.info("상품 게시글 삭제하기");
postService.deletePost(id);
return ResponseEntity.status(HttpStatus.OK).build();
}
}- URL:
danggun/posts - Method:
POST - Body:
{ "title":"책상입니다", "price": 2000, "content": "완전 새거같아요", "category": { "id": 1 }, "user": { "id": 1 } }{ "title":"과자 팔아요", "price": 500, "content": "이거 맛있어요", "category": { "id": 2 }, "user": { "id": 1 } }
- URL:
danggun/posts - Method:
GET
- URL:
danggun/posts/<int:pk> - Method:
GET
- URL:
danggun/posts/<int:pk> - Method:
DELETE
Post 객체로 CRUD API를 만들어 보면서 DTO가 왜 필요하고 Controller와 Service 계층이 구체적으로 어떻게 동작하는지 이해할 수 있었다. 특히 외래키로 연결된 데이터들에 대해서 어떻게 request를 받고, 어떤 response를 보내줄지 고민을 많이 했던 것 같다. 해당 부분은 내가 코드를 작성하면서 뜯어보고 프로그램이 동작하는 것을 눈으로 보는 게 빠르게 학습할 수 있는 방법인 것 같다.
- softDelete로 수정
JWT는 JSON Web Token의 약자로 사용자를 인증하고 식별하는데 필요한 JSON 데이터들을 URL로 이용할 수 있는 문자(Base64 URL-safe Encode)로 인코딩하여 직렬화, 암호화시킨 토큰을 말한다. 전자 서명도 있어 JSON의 변조를 체크할 수 있다. 단순히 HTTP 요청시 헤더에 토큰을 첨부하는 것만으로 데이터를 요청하고 응답을 받아올 수 있다. JWT를 생성하기 위해서는 Header, Payload, Verify Signature 객체를 필요로 한다. Header, Payload는 누구나 디코딩하여 확인할 수 있기에 정보가 쉽게 노출될 수 있습니다. 하지만 Verify Signature는 SECRET KEY를 알지 못하면 복호화할 수 없어 보안에 좋다.
JWT는 Access Token만으로도 인증 방식을 구현할 수 있다. 하지만 한 번 발급되면 유효기간이 만료될 때까지 삭제를 할 수 없어 만료 전에 해커에게 정보가 털린다면 대처할 방법이 없다. 그렇다고 유효기간을 짧게 하면 자주 인증해야된 불편함이 생긴다. 이에 Access Token과 같은 형태인 Refresh Token을 같이 발급하여 이 문제를 해결할 수 있다. Refresh Token은 Access Token보다 긴 유효기간을 가지고, Access Token이 만료됐을 때 새로 발급해주는 열쇠가 된다.
OAuth는 외부서비스의 인증 및 권한부여를 관리하는 범용적인 프로토콜로, 카카오 로그인, 구글 로그인 등을 구현할 때 사용한다.
- 자원 서버(Resource Server): Client가 제어하고자 하는 자원 보유하고 있는 서버
- 자원 소유자(Resource Owner): 자원의 소유자(로그인하는 실제 사용자)
- 클라이언트(Client): 자원 서버에 접속해서 정보를 가져오고자 하는 클라이언트(우리의 웹 어플리케이션)
- 권한 서버(Authorization Server): 권한 관리 및 Access Token, Refresh Token을 발급해주는 서버(ex. 구글, 페이스북 등)
- Access Token: 자원 서버에 자원을 요청할 수 있는 토큰
- Refresh Token: 권한 서버에 접근 토큰을 요청할 수 있는 토큰
- 자원 소유자(사용자)가 구글 로그인을 요청한다.
- 클라이언트는 인증 서버에 로그인 페이지를 요청한다.
- 인증 서버가 로그인 페이지를 제공한다.
- 사용자는 제공받은 로그인 페이지에 ID와 비밀번호를 입력한다.
- 입력받은 값으로 인증 서버에 요청한다.
- 인증 서버에 Authorization code를 발급한다.
- 이 code로 인증 서버에 Access Token를 요청한다.
- 인증 서버에서 Access Token을 발급해준다.
- 인증이 완료되었다.
- 자원 서버에 Access Token을 담아 데이터를 요청한다.
- Access Token을 검증 후 응답을 준다. (만일 Access Token이 만료됐거나 위조되었다면, Client는 Authorization Server에 Refresh Token을 보내 Access Token을 재발급 받는다)
Key/Value 쌍으로 이루어진 문자열로, 사용자 브라우저에 저장된다. 용량이 제한되어 있으며 브라우저마다 쿠키 지원 형태가 달라 브라우저간 공유가 불가능하고 보안에 취약하다는 단점이 있다.
쿠키의 보안 문제를 해결할 수 있는데, Key/Value 쌍으로 이루어져 비밀번호같은 민감한 인증 정보를 브라우저가 아닌 서버 측에 저장하고 관리하는 것이다. 사용자가 많아지면 정보를 찾는 데이터 매칭에 오랜 시간이 걸리면서 부하가 가해질 수 있고 해커가 세션 ID 자체를 탈취하여 위장하여 접근할 수 있다는 한계가 있다.
로그인은 email과 pwd로 진행
- TokenProvider 클래스에 적절한 메서드 구현
@Slf4j
@Component
@RequiredArgsConstructor
public class TokenProvider implements InitializingBean {
@Value("${jwt.token.secret}")
private String secret; // 환경변수로 secret key 설정
private Key key;
private Long expireTimeMs = 1000 * 60 * 60L; // 1시간
private final PrincipalDetailsService principalDetailsService;
@Override
public void afterPropertiesSet() {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public String getAccessToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring("Bearer ".length());
}
return null;
}
public String createAccessToken(Long id, String email, Authentication authentication) {
String authorities =
authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
Claims claims = Jwts.claims(); // 일종의 map
claims.put("id", id);
claims.put("email", email); // 이메일(로그인 id) 넣어주기
claims.put("auth", authorities);
claims.put("type", "access");
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setSubject(email)
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expireTimeMs))
.signWith(key)
.compact()
;
}
// 토큰
public String getTokenUserEmail(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
return (String) claims.get("email"); // 이메일(로그인 id) 꺼내기
}
public Authentication getAuthentication(String token) {
PrincipalDetails principalDetails =
(PrincipalDetails)
principalDetailsService.loadUserByUsername(getTokenUserEmail(token));
// 여기의 return 값이 @AuthenticationPrincipal 사용시의 파라미터로 사용됨
return new UsernamePasswordAuthenticationToken(
principalDetails, token, principalDetails.getAuthorities());
}
public boolean validateAccessToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info(e.toString());
log.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.info(e.toString());
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info(e.toString());
log.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
log.info(e.toString());
log.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
}
스프링 시큐리티는 로그인 완료 시 Authenticication을 생성하게 되는데, Authenticication 객체는 UserDetails type으로 인증된 사용자 정보를 저장하기 때문이다.
따라서 UserDetails를 상속받는 PrincipalDetails와 PrincipalDetails를 생성하는 PrincipalDetailsService를 만들어주었다.
@Data
public class PrincipalDetails implements UserDetails {
private final User user;
public PrincipalDetails(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new SimpleGrantedAuthority("ROLE_USER")); // "USER" 권한
return collection;
}
@Override
public String getPassword() {
return user.getPwd();
}
// 이메일이 id 역할을 하기 때문에 user email 반환
@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;
}
}@Slf4j
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override // email로 user 찾아 UserDetails 타입의 PrincipalDetails 객체를 반환해준다
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
log.info("PrincipalDetailsService.loadUserByUsername");
log.info("Login");
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("존재하지 않는 유저입니다."));
return new PrincipalDetails(user);
}
}- TokenProvider를 이용해서 custom filter 내용 채우기
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String token = tokenProvider.getAccessToken(request);
String requestURI = request.getRequestURI();
if (token == null) {
filterChain.doFilter(request, response);
return;
}
if (StringUtils.isNotBlank(token) && tokenProvider.validateAccessToken(token)) {
Authentication authentication = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
logger.info("Security Context에 " + authentication.getName() + "인증 정보를 저장했습니다, uri: " + requestURI);
} else {
logger.info("유효한 JWT 토큰이 없습니다, uri: " + requestURI);
setErrorResponse(response, ErrorCode.INVALID_TOKEN);
}
filterChain.doFilter(request, response);
}
}JwtAuthenticationFilter는 HttpServletRequest에서 토큰 추출해서 토큰에 대한 유효성을 검사하고, 유효하다면 Authentication 객체를 생성해서 SecurityContextHolder에 추가하는 역할을 한다.
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtExceptionHandlerFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (ExpiredJwtException e) {
log.error("만료된 토큰입니다");
setErrorResponse(response, ErrorCode.EXPIRED_TOKEN);
} catch (JwtException | IllegalArgumentException e) {
log.error("유효하지 않은 토큰이 입력되었습니다.");
setErrorResponse(response, ErrorCode.INVALID_TOKEN);
} catch (NoSuchElementException e) {
log.error("사용자를 찾을 수 없습니다.");
setErrorResponse(response, ErrorCode.USEREMAIL_NOT_FOUND);
} catch (ArrayIndexOutOfBoundsException e) {
log.error("토큰을 추출할 수 없습니다.");
setErrorResponse(response, ErrorCode.INVALID_TOKEN);
} catch (NullPointerException e) {
filterChain.doFilter(request, response);
}
}
}JwtExceptionHandlerFilter는 JwtAuthenticationFilter 전에 호출되어 Security 필터에서 발생하는 오류를 예외처리한다.
- 앞에서 구현한
TokenProvider를 이용해요 - 연결한 DB에 회원을 만든 후, 로그인 API가 잘 작동하는지 테스트를 해봐요(회원가입 API를 만들어서 테스트 한다면 더욱 좋겠죠?)
@Service
@RequiredArgsConstructor
public class UserService {
private final UserHelper userHelper;
private final TokenProvider tokenProvider;
public TokenResponse login(UserLoginRequest request){
String email = request.getEmail();
String pwd = request.getPwd();
final User selectedUser = userHelper.findByEmail(email);
final Authentication authentication = userHelper.adminAuthorizationInput(selectedUser); // 유저의 권한 반환
// password 맞는지 확인하기
userHelper.validatePwd(selectedUser, pwd);
//access 토큰 생성
String accessToken = tokenProvider.createAccessToken(selectedUser.getId(), selectedUser.getEmail(), authentication);
return TokenResponse.from(accessToken);
}
}@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping("/login")
public ResponseEntity<TokenResponse> login(@RequestBody UserLoginRequest request){
//로그인 시 TokenResponse return
return ResponseEntity.status(HttpStatus.CREATED).body(userService.login(request));
}
}- 구현 후 API 테스트를 해봐요 (POST API)
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class WebSecurityConfig {
private final JwtExceptionHandlerFilter jwtExceptionHandlerFilter;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
http
.httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.disable())
.formLogin(formLogin -> formLogin.disable())
.cors(Customizer.withDefaults())
.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(HttpMethod.POST, "/login").permitAll()
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/posts/**").hasAnyRole("USER") // USER 권한 있어야지 POST 가능
.requestMatchers(HttpMethod.DELETE, "/api/posts/**").hasAnyRole("USER")
.anyRequest().authenticated()
);
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
http.addFilterBefore(jwtExceptionHandlerFilter, JwtAuthenticationFilter.class);
return http.build();
}
}public class PostController {
@PostMapping
public ResponseEntity<Long> createPost(
@RequestBody @Valid PostRequestDto request,
@AuthenticationPrincipal PrincipalDetails user) {
//@AuthenticationPrincipal 이용해서 PrincipalDetails type의 로그인 유저 정보 가지고 오기
log.info("상품 게시글 생성하기");
postService.createPost(request, user.getUser());
return ResponseEntity.status(HttpStatus.CREATED).build();
}
...
@DeleteMapping("/{id}")
public ResponseEntity<Void> deletePost(
@PathVariable Long id,
@AuthenticationPrincipal PrincipalDetails user) {
log.info("상품 게시글 삭제하기");
postService.deletePost(id, user.getUser());
return ResponseEntity.status(HttpStatus.OK).build();
}
}- 요청에 토큰이 포함되지 않았다면 어떻게 할까요?
public class JwtAuthenticationFilter extends OncePerRequestFilter {
...
if (token == null) {
// 값이 들어오지 않은 경우
logger.info("토큰을 찾을 수 없습니다.");
filterChain.doFilter(request, response);
return;
}
if (StringUtils.isNotBlank(token) && tokenProvider.validateAccessToken(token)) {
...
//잘못된 token인 경우
logger.info("Security Context에 " + authentication.getName() + "인증 정보를 저장했습니다, uri: " + requestURI);
} else {
logger.info("유효한 JWT 토큰이 없습니다, uri: " + requestURI);
setErrorResponse(response, ErrorCode.INVALID_TOKEN);
...- https://velog.io/@jsang_log/Security-Filter-%EC%98%88%EC%99%B8%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0-JWT
- https://ws-pace.tistory.com/251
- https://velog.io/@seho100/Spring-boot%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-JWT-%EA%B5%AC%ED%98%84
스프링 Security랑 jwt 토큰을 연동해서 제대로 구현해본 건 이번이 처음인데, configuration, dependency 등 구현해야하는 내용도 많고 복잡하다보니 정말 쉽지 않은 과제였다. 개발 도중 자잘한 에러들이 많았는데, 에러를 해결하면서 힘들었던 점은 어디서 에러가 생겼는지 바로 알기 어렵다는 것이었다. 처음에 코드를 짤 때부터 생길 수 있는 여러가지 예외사항을 고려해서 예외/response/log 등을 반환해주는 것이 중요하다는 것을 느꼈다. 이번에 제대로 구현을 해보았으니 다음에 개발할 때는 개발 속도가 좀 더 빨라졌으면 좋겠고, refresh 토큰도 구현해보고 싶다.
Docker의 개념에 대해 알아보자면, 도커는 다양한 의존성을 추상화하여 어떤 환경에서든 실행하기 위해 필요한 모든 의존성을 포함하는 패키지로, 서비스 파일이 어디서나 동일하게 실행될 수 있도록 하는 환경을 조성해주는 것이라고 생각하면 된다. 그래서 서비스 운영에 필요한 서버 프로그램, 소스코드나 라이브러리, 컴파일된 실행 파일을 묶은 형태를 Docker Image 라고 한다. (jpeg 같은 이미지가 아니다) 이 이미지를 바탕으로 서비스를 돌린 것이 Docker Container인 것이다.
도커 이미지(Docker Image)는 컨테이너(Container)를 만드는 데 사용되는 읽기 전용 템플릿(Read-only templates)이고, 컨테이너(Docner Container)는 이러한 템플릿에서 생성된 배포된 인스턴스(Deployed Instances)라고 생각하면 좋다.
FROM openjdk:20
ARG JAR_FILE=/build/libs/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar", "/app.jar"]
version: "3"
services:
web:
container_name: web
build: .
ports:
- "8080:8080"
depends_on:
- db
environment:
- SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/ceos18
- SPRING_DATASOURCE_USERNAME=root
- SPRING_DATASOURCE_PASSWORD=
volumes:
- .:/app
db:
image: mysql:5.7
environment:
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
MYSQL_DATABASE: ceos18
volumes:
- dbdata:/var/lib/mysql
ports:
- 3306:3306
restart: always
volumes:
app:
dbdata:- gradle - build - bootJar 실행
- Dockerfile과 docker-compose.yml 작성
- Docker desktop 켜주기
- 프로젝트 터미널에서 도커 이미지 파일 생성
$ docker build -t nzeong/spring-boot .
- 도커 이미지 목록 보기
$ docker images
- docker image build
$ docker run -p 8080:8080 -e SPRING_DATASOURCE_URL=jdbc:mysql://host.docker.internal:3306/ceos18 -e SPRING_DATASOURCE_USERNAME=root -e SPRING_DATASOURCE_PASSWORD="" nzeong/spring-boot
- docker-compose 실행
docker-compose -f docker-compose.yml up --build
- ec2 인스턴스 생성
- 탄력적 ip 만들어서 ec2 인스턴스와 연결해주기
- 잘 들어가지는지 터미널에서 ssh 연결 확인하기 (.pem 있는 위치, 웬만하면 git으로 관리 안하는 디렉토리)
$ chmod 400 danggun.pem
$ ssh -i "danggun.pem" ubuntu@ec2-15-164-196-67.ap-northeast-2.compute.amazonaws.com
- rds 인스턴스 생성 (보안그룹 새로 생성, 스토리지 자동 조정을 비활성화) -> 보안그룹 설정 -> 파라미터그룹 설정 -> MySql workbench에서 연결 확인하고 새 스키마 만들어주기
- ec2 인스턴스에 docker & docker-compose 설치
$ sudo curl \
-L "https://github.com/docker/compose/releases/download/1.26.2/docker-compose-$(uname -s)-$(uname -m)" \
-o /usr/local/bin/docker-compose
$ sudo chmod +x /usr/local/bin/docker-compose
$ docker-compose --version
- 도메인 사이트에서 도메인 등록 & route53 이용하여 도메인 등록
- Nginx Letsencrypt 적용 및 reverse proxy 설정하기
- Certbot 사용하여 ssl 인증서 발급받고 nginx 이미지 docker-compose로 생성 및 실행(ec2 서버에서 진행)
- 참고
- /etc/nginx 위치에 app.conf 작성해줌
- root 위치에 docker-compose.yml 작성해줌
version: '3'
services:
nginx:
image: nginx:1.15-alpine
restart: unless-stopped
volumes:
- ./data/nginx:/etc/nginx/app.conf
- /etc/letsencrypt:/etc/letsencrypt
- /var/log/nginx/mytamla:/var/log/nginx/danggun
ports:
- "80:80"
- "443:443"
command:
"/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
여기에서 명령어 이용해서 nginx:1.15-alpine 컨테이너 실행시켜준다! -> 실행은 잘 되는데 인증서 발급에서 오류가 나서 아직 시도 중
docker-compose up -d
- github CI/CD
- action 변수들 등록 해주었다
- 도메인 링크: http://danggun-ceos.kro.kr:8080/
- Postman / 브라우저를 통해 요청/응답을 테스트합니다.
- HTTP → HTTPS 리디렉션이 제대로 이루어지는지 확인
- 구현한 API 하나 이상 제대로 응답하는지 확인
나는 ec2 서버에서 http->https 연결해주는 nginx 컨테이너를 docker-compose로 띄우고, github action으로는 spring 컨테이너를 docker-compose-dev로 띄우는 식으로 따로 따로 구현하려고 했다. 근데 이렇게 하는 것보단 그냥 아예 다 github action으로 해도 좋을 것 같기도 하고 고민된다. 현재 문제가 있는 부분은 다음과 같다.
- http -> https 리다이렉트를 위한 인증서 발급과 적용
- 1번이 적용된 nginx 컨테이너 띄우기
- nginx 컨테이너와 spring 컨테이너의 연결(같이 띄우기)
앞으로 오류를 수정해나가보겠다.
