Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Spring Data JPA] 안금서 미션 제출합니다. #110

Open
wants to merge 50 commits into
base: goldm0ng
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
5913de1
<ADD> gradle 의존성 추가
goldm0ng Dec 26, 2024
67da617
<ADD> 토큰 기반 인증 방식 jwt 관련 기능 추가
goldm0ng Dec 26, 2024
53828d8
<FIX> MemberRequest DTO를 record 형식으로 변경
goldm0ng Dec 26, 2024
0ec5186
<ADD> jwt를 사용한 로그인 및 인증 정보 조회 기능 구현
goldm0ng Dec 26, 2024
ebf6407
<ADD> 로그인 사용자 정보인 MemberAuthInfo를 조회하는 HandlerMethodArgumentResolver 구현
goldm0ng Dec 26, 2024
22f9e30
<FIX> HandlerMethodArgumentResolver 구현에 따른 예약 API 및 기능 리팩터링
goldm0ng Dec 26, 2024
347d0c7
<ADD> 관리자 기능 구현
goldm0ng Dec 26, 2024
2a96836
<ADD> 사용자의 정보를 조회하는 ArgumentResolver와 관리자만 해당 경로에 접근할 수 있도록 하는 Interc…
goldm0ng Dec 26, 2024
91bf7bf
<ADD> 예외처리 추가
goldm0ng Dec 26, 2024
16e217b
<ADD> 1 ~ 3단계 미션 테스트 추가
goldm0ng Dec 26, 2024
4a9ff8c
<FIX> DTO record 타입으로 변경
goldm0ng Jan 7, 2025
a5e8d66
<FIX> DTO record 타입으로 변경에 따른 수정
goldm0ng Jan 7, 2025
e027b31
<FIX> 응답 바디 타입 수정
goldm0ng Jan 7, 2025
c7a1875
<FIX> 인증 패키지 구조 변경
goldm0ng Jan 8, 2025
2358568
<FIX> JwtUtils 관련 수정 (멤버 변수 및 메서드)
goldm0ng Jan 8, 2025
b44dab1
<FIX> 에러 추적 용이하도록 에러 포함
goldm0ng Jan 8, 2025
330b40c
<FIX> gradle 의존성 jdbc -> jpa로 대체
goldm0ng Jan 8, 2025
7a7ed55
<ADD> JPA 관련 설정 추가
goldm0ng Jan 8, 2025
c5f4f97
<ADD> 초기 데이터베이스 seed 데이터 추가 및 기존 스키마 삭제
goldm0ng Jan 8, 2025
580c6ab
<FIX> Time 도메인 엔티티 매핑 및 JPA 전환
goldm0ng Jan 8, 2025
edfbc2c
<FIX> Theme 도메인 엔티티 매핑 및 JPA 전환
goldm0ng Jan 8, 2025
312bdea
<FIX> Reservation 도메인 엔티티 매핑 및 JPA 전환
goldm0ng Jan 8, 2025
bef9160
<FIX> Member 도메인 엔티티 매핑 및 JPA 전환
goldm0ng Jan 8, 2025
f164ce0
<FIX> JPA 전환에 따른 LoginService 수정 및 예외처리 방식 변경
goldm0ng Jan 8, 2025
e2a32bc
<ADD> 4단계 테스트 코드 추가
goldm0ng Jan 8, 2025
9c5e253
<FIX> Spring MVC (인증) 미션 테스트 이름 수정
goldm0ng Jan 8, 2025
0840983
<FIX> 초기값 설정을 위한 쿼리 수정
goldm0ng Jan 9, 2025
da71532
<FIX> 회원 인증 정보 DTO 수정 (id 정보 추가)
goldm0ng Jan 9, 2025
84f4e41
<ADD> 내 예약 목록 응답 DTO 추가
goldm0ng Jan 9, 2025
fd7a976
<FIX> Reservation - Member 추가 연관관계 매핑
goldm0ng Jan 9, 2025
fcf74d3
<FIX> Reservation DTO를 record 타입으로 변경
goldm0ng Jan 9, 2025
54d412e
<ADD> 내 예약 목록 조회 기능 구현
goldm0ng Jan 9, 2025
a4b3705
<FIX> JwtUtils id 추출 코드 수정
goldm0ng Jan 9, 2025
b8d792b
<ADD> 사용자 정의 예외 생성 및 핸들러에 추가
goldm0ng Jan 9, 2025
aba712d
<ADD> 5단계 테스트 코드 추가 및 전 단계 테스트 수정
goldm0ng Jan 9, 2025
d472034
<ADD> 예약 대기 요청 및 취소 기능 구현
goldm0ng Jan 9, 2025
751587d
<ADD> 예약 중복 방지 및 내 예약 조회 시 예약 대기 목록까지 보이도록 구현
goldm0ng Jan 9, 2025
e963234
<ADD> 중복 예약 관련 커스텀 예외 추가
goldm0ng Jan 9, 2025
8b292c3
<ADD> 6단계 테스트 코드 추가 및 테스트 깨지는 부분 수정
goldm0ng Jan 9, 2025
39a0f7d
<FIX> 예약 대기 취소 구현 오류 해결
goldm0ng Jan 13, 2025
e7e5c7b
<FIX> 공통 예외 처리 핸들러 중복 코드 제거
goldm0ng Jan 13, 2025
a80573b
<FIX> 예외 처리 오류 2차 해결 및 로깅 방식 수정
goldm0ng Jan 14, 2025
08017a7
<FIX> JPA 쿼리 메소드 적용
goldm0ng Jan 14, 2025
35ec5f7
<FIX> 기본 생성자 접근지정자 변경 및 생성 어노테이션 추가
goldm0ng Jan 14, 2025
1959159
<ADD> 변경에 유연하도록 인터페이스 추가 및 jwt 인증 구현체 추가
goldm0ng Jan 10, 2025
b4bf7fb
<FIX> 기존 JwtUtils를 클래스 속성에 맞도록 이름 수정
goldm0ng Jan 10, 2025
c74ea76
<ADD> jwt 인증 로직을 모아놓은 service 추가
goldm0ng Jan 10, 2025
c4d0384
<FIX> 인증 응답 이름 더 포괄적으로 수정
goldm0ng Jan 10, 2025
a25995f
<FIX> jwt 패키지와의 강한 결합 분리
goldm0ng Jan 10, 2025
4c8b450
Merge branch 'goldm0ng' into spring-data-jpa
goldm0ng Jan 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ repositories {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

implementation 'dev.akkinoc.spring.boot:logback-access-spring-boot-starter:4.0.0'

Expand Down
14 changes: 0 additions & 14 deletions src/main/java/roomescape/ExceptionController.java

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package roomescape.authentication;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import roomescape.authentication.jwt.JwtResponse;
import roomescape.authentication.jwt.JwtUtils;

@Component
@RequiredArgsConstructor
public class AuthAdminRoleInterceptor implements HandlerInterceptor {

private final JwtUtils jwtUtils;

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
JwtResponse jwtResponse = jwtUtils.extractTokenFromCookie(request.getCookies());
if (jwtResponse.accessToken() == null || !isAdmin(jwtResponse.accessToken())) {
response.setStatus(401);
return false;
}

return true;
}

private boolean isAdmin(String token) {
MemberAuthInfo memberAuthInfo = jwtUtils.extractMemberAuthInfoFromToken(token);
return "ADMIN".equals(memberAuthInfo.role());
}
}
28 changes: 28 additions & 0 deletions src/main/java/roomescape/authentication/AuthenticationConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package roomescape.authentication;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@Configuration
@RequiredArgsConstructor
public class AuthenticationConfig implements WebMvcConfigurer {

private final LoginMemberArgumentResolver loginMemberArgumentResolver;
private final AuthAdminRoleInterceptor authAdminRoleInterceptor;

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(loginMemberArgumentResolver);
}

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authAdminRoleInterceptor)
.addPathPatterns("/admin/**");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package roomescape.authentication;

import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import roomescape.authentication.jwt.JwtResponse;
import roomescape.authentication.jwt.JwtUtils;

@Component
@RequiredArgsConstructor
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {

private final JwtUtils jwtUtils;

@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterType().equals(MemberAuthInfo.class);
}

@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
HttpServletRequest request = ((ServletWebRequest) webRequest).getRequest();

JwtResponse jwtResponse = jwtUtils.extractTokenFromCookie(request.getCookies());
if (jwtResponse.accessToken() == null) {
return null;
}

return jwtUtils.extractMemberAuthInfoFromToken(jwtResponse.accessToken());
}
}
7 changes: 7 additions & 0 deletions src/main/java/roomescape/authentication/MemberAuthInfo.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package roomescape.authentication;

public record MemberAuthInfo(
Long id,
String name,
String role) {
}
6 changes: 6 additions & 0 deletions src/main/java/roomescape/authentication/jwt/JwtResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package roomescape.authentication.jwt;

public record JwtResponse(
String accessToken
) {
}
79 changes: 79 additions & 0 deletions src/main/java/roomescape/authentication/jwt/JwtUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package roomescape.authentication.jwt;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.Cookie;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import roomescape.authentication.MemberAuthInfo;
import roomescape.exception.JwtProviderException;
import roomescape.exception.JwtValidationException;
import roomescape.member.Member;

import java.util.Arrays;

@Component
@RequiredArgsConstructor
public class JwtUtils {

@Value("${roomescape.auth.jwt.secret}")
private String secretKey;

public JwtResponse createAccessToken(Member member) {
try {
String accessToken = Jwts.builder()
.setSubject(member.getId().toString())
.claim("name", member.getName())
.claim("role", member.getRole())
.signWith(Keys.hmacShaKeyFor(secretKey.getBytes()))
.compact();

return new JwtResponse(accessToken);
} catch (JwtException e) {
throw new JwtProviderException("JWT 생성에 실패하였습니다.", e);
}
}

public MemberAuthInfo extractMemberAuthInfoFromToken(String token) {
if (token == null || token.isEmpty()) {
throw new JwtValidationException("토큰이 존재하지 않습니다.");
}

try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes()))
.build()
.parseClaimsJws(token)
.getBody();

Long id = Long.valueOf(claims.getSubject());
String name = claims.get("name", String.class);
String role = claims.get("role", String.class);

return new MemberAuthInfo(id, name, role);
} catch (JwtException e) {
throw new JwtValidationException("유효하지 않은 JWT 토큰입니다.", e);
}
}

public JwtResponse extractTokenFromCookie(Cookie[] cookies) {
if (cookies == null) {
throw new JwtValidationException("쿠키가 존재하지 않습니다.");
}

try {
String accessToken = Arrays.stream(cookies)
.filter(cookie -> cookie.getName().equals("token"))
.map(Cookie::getValue)
.findFirst()
.orElseThrow(() -> new JwtValidationException("토큰이 존재하지 않습니다."));

return new JwtResponse(accessToken);
} catch (Exception e) {
throw new JwtValidationException("쿠키에서 토큰 추출 중 오류가 발생했습니다.", e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package roomescape.exception;

public class DuplicateReservationException extends RuntimeException {
public DuplicateReservationException(String message) {
super(message);
}
}
46 changes: 46 additions & 0 deletions src/main/java/roomescape/exception/GeneralExceptionHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package roomescape.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GeneralExceptionHandler {

@ExceptionHandler(DuplicateReservationException.class)
public ResponseEntity<String> handleDuplicatedReservation(DuplicateReservationException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(e.getMessage());
}

@ExceptionHandler(TimeNotFoundException.class)
public ResponseEntity<String> handleTimeNotFound(TimeNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage());
}

@ExceptionHandler(ThemeNotFoundException.class)
public ResponseEntity<String> handleThemeNotFound(ThemeNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage());
}

@ExceptionHandler(MemberNotFoundException.class)
public ResponseEntity<String> handleMemberNotFound(MemberNotFoundException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage());
}

@ExceptionHandler(JwtValidationException.class)
public ResponseEntity<String> handleJwtValidationException(JwtValidationException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage());
}

@ExceptionHandler(JwtProviderException.class)
public ResponseEntity<String> handleJwtProviderException(JwtProviderException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage());
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이렇게 로직이 같은 것들은

Suggested change
@ExceptionHandler(MemberNotFoundException.class)
public ResponseEntity<String> handleMemberNotFound(MemberNotFoundException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage());
}
@ExceptionHandler(JwtValidationException.class)
public ResponseEntity<String> handleJwtValidationException(JwtValidationException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage());
}
@ExceptionHandler(JwtProviderException.class)
public ResponseEntity<String> handleJwtProviderException(JwtProviderException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage());
}
@ExceptionHandler(MemberNotFoundException.class, JwtValidationException.class, JwtProviderException.class)
public ResponseEntity<String> handleUnauthorized(Exception e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage());
}

같은 느낌으로 통일해보면 어떨까요?
중복을 줄일 수 있을 것 같아요

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네 좋습니다! 훨씬 깔끔하고 좋은 방식이네요.
잦은 코드 중복에 유의해야겠어요!


@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleGeneralException(Exception e) {
e.printStackTrace();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

e.printStackTrace 함수는 성능상으로 되게 안좋은 함수인데요
https://dev-jwblog.tistory.com/167 요것을 참고해서 로깅을 진행해보면 어떨까요?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

코드를 한줄 한줄 짤 때, 구현에 초점을 맞춰서 짜기 바빴어서 성능의 측면에서 생각해 보지는 못 했던 것 같네요!
실제 프로덕트를 운영하는 프로젝트를 할 때는 이런 사소한 것들이 발목을 잡는 경우가 많을 것 같아요.
앞으로 학습하고 코드를 짤 때는 성능도 함께 고려해야겠습니다!

Copy link
Author

@goldm0ng goldm0ng Jan 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

언급해주신 참고 자료 잘 읽어보았습니다.
e.printStactTrace를 지양해야 하는 이유를 적어놓겠습니다!

<e.printStackTrace() 사용을 지양해야 하는 이유>

  1. System.err 로 쓰여지게 되면서 제어하기 어렵고, 리소스 비용이 비싼편이다.
    • System.err는 표준 오류 스트림이고, 기본적으로 콘솔에 출력되는데, 이 작업은 비교적 느리다. 특히, 많은 예외가 발생하거나 고빈도로 호출될 경우, 콘솔 출력이 성능 병목을 일으킬 수 있다.
  2. Java의 Reflection을 사용해 예외를 추적하는 것이라 많은 오버헤드가 발생한다.
  3. 서버에서 메소드 스택정보를 취합하기 때문에 서버 부하의 원인이 된다.
    • e.printStackTrace()는 모든 스택 트레이스를 출력한다.
      하지만, 모든 정보가 필요한 것은 아니며, 필요한 정보만 선별적으로 로그에 기록해야 한다. 긴 스택 트레이스를 콘솔에 출력하면 가독성이 떨어지고, 로그 파일 크기가 불필요하게 커질 수 있다.
  4. 출력이 어디로 향하는지 파악하기 어렵다.
  5. 관리가 어렵다.
  • 로그 프레임워크(Logback, Log4j 등)를 사용하면 파일, 원격 서버, 콘솔 등 다양한 대상으로 로그를 출력할 수 있지만, printStackTrace()는 항상 표준 오류 스트림으로 출력한다. 이러한 제한은 로깅 설정을 통해 로그를 관리하고 최적화하려는 시도와 상충된다.
  • 로그 프레임워크는 TRACE, DEBUG, INFO, WARN, ERROR 등의 수준(Level)을 제공하여 로그의 중요도를 구분할 수 있는데, e,printStackTrace는 이러한 수준을 제공하지 않아 로그 관리가 어렵다.
  1. 운영 환경에서의 문제가 발생할 수 있다.
  • 운영 환경에서 e.printStackTrace()로 인해 민감한 정보가 콘솔에 출력될 경우 보안 문제를 유발할 가능성이 있다.
  • 표준 오류 스트림이 파일로 리다이렉션 되지 않으면, 로그가 제대로 저장되지 않거나 손실될 가능성이 있다.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

e.printStackTrace 말고 로깅을 하는 다른 방법은 여러 가지가 있는데,
e.getStackTrace 와 e.getMessage, e.ToString 등을 로그 프레임워크와 함께 필요에 따라 적절히 조합해서 사용하는 게 좋은 방법이겠네요!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋아요! slf4j 에 기본적으로 throwable 을 받는 인터페이스가 정의되어있는데요
요 부분에서 어떻게 처리하는지 봐도 좋을 것 같아요!
스크린샷 2025-01-15 오후 9 53 32

return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage());

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

보통
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage());
와 같은 진짜 뭔지 모르는 곳에서 터지는 에러의 경우에는 e.getMessage() 가 서버의 중요한 부분을 담고 있을 수도 있어서 이런 케이스에서는 그냥 body 에 "잠깐 문제가 생겼어요. 다음에 다시 시도해주세요" 같은 문구를 내려주는 편입니다!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아하 그렇군요!
Q1. 예를 들면 어떤 중요한 부분 말씀이신가요? 감이 잘 잡히지 않네요!
Q2. 그리고 누누님은 예외 응답을 내려줄 때 중대한 문제를 일으킨 경험이 있으신가요? 있다면 어떤 문제였고, 어떻게 해결하셨는지도 궁금합니다! (꼭 예외 응답을 내려줄 때가 아니어도 예외처리 관련한 경험이 있으시다면 말씀해주세요!)

Copy link

@be-student be-student Jan 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q1 > 서버에 어떤 라이브러리를 사용하고 있는지와 같은 정보들이 전달되는 것 자체가 문제가 될 수 있어요!
Q2 > 저는 예외 응답을 만들다가 예외가 터졌던 기억이 나네요
근데, 예외 응답을 만드는 곳도(catch 문) 다시 한번 감싸져서 기본 응답을 주도록 되어있어서 문제 없이 동작했던 경험이 있어요!

}
}
7 changes: 7 additions & 0 deletions src/main/java/roomescape/exception/JwtProviderException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package roomescape.exception;

public class JwtProviderException extends RuntimeException {
public JwtProviderException(String message, Throwable cause) {
super(message, cause);
}
}
11 changes: 11 additions & 0 deletions src/main/java/roomescape/exception/JwtValidationException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package roomescape.exception;

public class JwtValidationException extends RuntimeException {
public JwtValidationException(String message) {
super(message);
}

public JwtValidationException(String message, Throwable cause) {
super(message, cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package roomescape.exception;

public class MemberNotFoundException extends RuntimeException {

public MemberNotFoundException(String message) {
super(message);
}
}
16 changes: 16 additions & 0 deletions src/main/java/roomescape/exception/PageExceptionHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package roomescape.exception;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import roomescape.PageController;

@Slf4j
@ControllerAdvice(assignableTypes = PageController.class)
public class PageExceptionHandler {
@ExceptionHandler(Exception.class)
public String handleException(Exception e) {
log.error("error: " + e.getMessage());
return "error/500"; //view 렌더링 페이지는 만들지 않음
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 이건 처음보는데 열심히 찾아보셨군요! 👍

Copy link
Author

@goldm0ng goldm0ng Jan 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

우선, GeneralExceptionHandler와 PageExceptionHandler를 나눈 이유는 예외처리를 할 때 상황에 따라 응답을 다르게 해주기 위해서입니다.

현재 구현되어 있는 컨트롤러들은 역할에 따라 크게 두가지로 나눌 수 있을 것 같아요.

  1. HTML 페이지를 렌더링 하는 역할을 하는 PageController
  2. API 요청을 처리하는 그 외 나머지 Controller( ex: MemberController, ReservationController, WaitingController, TimeController, ThemeController 등)

만약, 이 두 컨트롤러에서 500 에러와 같은 예상치 못한 예외가 발생한다면?

같은 예외라 하더라도,
PageController는 오류 페이지를 랜더링해서 보여주는 방식으로 응답을 내릴 수 있고,
그 외 다른 API 컨트롤러는 응답 바디에 상태 코드를 넣고 오류 메세지를 전달하는 방식으로 내릴 수 있습니다.

1번의 컨트롤러의 경우, 뷰 렌더링 과정에서 문제가 생기면 원래 받아야할 HTML 응답과 비슷한 형태의 예외 응답을 주는 것이 적절할 것이라고 판단하였습니다! 위 코드처럼 예외 페이지를 응답하거나 리다이렉트를 하는 방향으로요!

2번의 컨트롤러의 경우 상태 코드나, 오류 메세지 등을 전달해주는 방식으로 예외 응답을 줘서 프론트 측에서 예외 응답에 따라 적절한 조치를 취할 수 있도록 하는 것이죠.

Q. 누누님은 이런 방식에 대해 어떻게 생각하시나요?
그리고 실제로 이런식으로 행해지는 것인지도 궁금하네요!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

솔직히 말씀드려서 말씀해주신 2가지가 같이 케이스는 없었던 것 같아요
일반적으로는 html 을 렌더링하는 controller 가 없으니까요! (프론트가 있다보니...)

1번은 정확하게 모르겠어요 저도 실무에서는 전혀 볼 일이 없는 코드다보니...?
비슷하게 응답하는 방법처럼 진행해주신 방법은 좋은 것 같습니다! 일반 응답과 많이 달라지면 그 처리를 클라이언트에서 하지 못해서 에러가 많이 발생하는 것 같아요

2번의 경우에는 실제로 많이 쓰는데요
상태 코드 + 오류 메시지 + 서버의 오류 타입을 추가해서 이렇게 총 3가지를 내려주는 것 같아요
상태코드 400 에도 다양한 에러 메시지가 있을텐데, 이거를 프론트에서 분기하려면 오류 메시지를 의존하는 것 보다는 오류 타입에 의존하는 것이 좋은 것 같아요

{
  "message":"요청에 시간이 없습니다",
  "type":"invalid_time"
}

같은 형태이려나요?

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package roomescape.exception;

public class ThemeNotFoundException extends RuntimeException {
public ThemeNotFoundException(String message) {
super(message);
}
}
7 changes: 7 additions & 0 deletions src/main/java/roomescape/exception/TimeNotFoundException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package roomescape.exception;

public class TimeNotFoundException extends RuntimeException {
public TimeNotFoundException(String message) {
super(message);
}
}
4 changes: 4 additions & 0 deletions src/main/java/roomescape/login/LoginCheckResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package roomescape.login;

public record LoginCheckResponse(String name) {
}
50 changes: 50 additions & 0 deletions src/main/java/roomescape/login/LoginController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package roomescape.login;

import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import roomescape.authentication.MemberAuthInfo;
import roomescape.authentication.jwt.JwtResponse;
import roomescape.authentication.jwt.JwtUtils;

@RestController
@RequiredArgsConstructor
public class LoginController {

private final LoginService loginService;
private final JwtUtils jwtUtils;

@PostMapping("/login")
public void login(@Valid @RequestBody LoginRequest loginRequest, HttpServletResponse response) {
JwtResponse jwtResponse = loginService.login(loginRequest);

Cookie cookie = new Cookie("token", jwtResponse.accessToken());
cookie.setHttpOnly(true);
cookie.setPath("/");
response.addCookie(cookie);
}

@GetMapping("/login/check")
public LoginCheckResponse checkLogin(HttpServletRequest request) {
JwtResponse jwtResponse = jwtUtils.extractTokenFromCookie(request.getCookies());
MemberAuthInfo memberAuthInfo = jwtUtils.extractMemberAuthInfoFromToken(jwtResponse.accessToken());
LoginCheckResponse loginCheckResponse = loginService.checkLogin(memberAuthInfo);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

개인 취향이긴 하지만 보통 저는 이 로직을 담은 서비스를 선호하기는 합니다!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제가 누누님이 하신 말씀이 정확히 이해했는지는 잘 모르겠는데,
누누님은 보통 저 세줄의 로직을 보통 서비스의 한 메서드 로직으로 쓰신다는 말씀이신가요~?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 맞아요
한 컨트롤러의 한 api 에서는 한 service 의 메소드만을 호출하는 것이 가장 익숙했던 것 같아요


return loginCheckResponse;
}

@PostMapping("/logout")
public void logout(HttpServletResponse response) {
Cookie cookie = new Cookie("token", "");
cookie.setHttpOnly(true);
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);
}
}
Loading