Skip to content
Open
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
3d3151e
refactor: Id로 맴버 조회 dao 추가
mintcoke123 Dec 26, 2025
0c3e685
feat: POST/login 추가
mintcoke123 Dec 27, 2025
8b26e96
feat: 토큰 조회
mintcoke123 Dec 27, 2025
378e7c5
refactor: 키 제사용 가능하게 변경
mintcoke123 Dec 27, 2025
cfd15f6
test: 이단계 테스트 추가
mintcoke123 Dec 27, 2025
b456b89
로그인맴버 dto 추가
mintcoke123 Dec 27, 2025
97cf233
feat: resolver,config 추가
mintcoke123 Dec 27, 2025
451beff
refactor: reservationRequest에 생성자 생성
mintcoke123 Dec 27, 2025
f1e3288
refactor: createToken 메서드분리
mintcoke123 Dec 27, 2025
0d5c991
test: 테스트코드 리팩토링
mintcoke123 Dec 27, 2025
f6a7db0
refactor: 테스트코드대로 컨트롤러 수정
mintcoke123 Dec 27, 2025
20d7526
test: 삼단계 테스트코드 작성
mintcoke123 Dec 27, 2025
1357d07
feat: interceptor 구현
mintcoke123 Dec 27, 2025
12171bb
refactor: auth의 중복 제거
mintcoke123 Dec 31, 2025
1eb6513
refactor: 프로퍼티 주입으로 시크릿 키 관리
mintcoke123 Dec 31, 2025
a108bc6
refactor: application.properties 수정
mintcoke123 Dec 31, 2025
069e91f
test: 테스트코드 리팩토링
mintcoke123 Dec 31, 2025
f7a7485
refactor: 생성자 주입으로 테스트코드 변경
mintcoke123 Dec 31, 2025
b4869c1
test: truncate 데이터 구축
mintcoke123 Dec 31, 2025
97e0106
refactor: 검증을 requestDto단에서 적용하도록 변경
mintcoke123 Dec 31, 2025
4b90a36
refactor: 어드민 페이지 엔드포인트 수정
mintcoke123 Dec 31, 2025
9636a93
test: 미션 테스트코드 변경
mintcoke123 Jan 1, 2026
06b14d0
test: 미션 테스트코드 변경
mintcoke123 Jan 1, 2026
12f68bb
refactor: TimeDao에서 id로 time을 확인할 수 있게끔 변경
mintcoke123 Jan 1, 2026
ee61649
refactor: gradle 파일 수정
mintcoke123 Jan 1, 2026
589badc
test: 테스트코드 추가, dao를 jpa 방식으로 변경하며 삭제
mintcoke123 Jan 1, 2026
d70f3c3
refactor: dao를 repository 방식으로 변경경
mintcoke123 Jan 1, 2026
ee530e2
refactor: JdbcTemplate 기반 Dao를 jpa 레포지토리로 리팩토링
mintcoke123 Jan 1, 2026
7554215
test: 테스트코드를 바뀐 코드에 걸맞게 변경
mintcoke123 Jan 1, 2026
8d6b22b
refactor: 스키마, 시드 정리
mintcoke123 Jan 1, 2026
3a20e9f
refactor: 멤버 엔티티 매핑
mintcoke123 Jan 1, 2026
aacf60c
refactor: 내 예약 responseDto 구현
mintcoke123 Jan 1, 2026
b935aca
refactor: LoginMember 주입받아 서비스 호출출
mintcoke123 Jan 1, 2026
43de312
refactor: ReservationRepository 리팩토링
mintcoke123 Jan 1, 2026
744396a
test: 테스트코드 변경
mintcoke123 Jan 1, 2026
be881da
refactor: 스키마 테이블, 테스트코드 테이블블 변경
mintcoke123 Jan 1, 2026
ce83b57
feat: waiting 엔티티 추가
mintcoke123 Jan 1, 2026
c61db0a
feat: WaitingService 및 WaitingController추가가
mintcoke123 Jan 1, 2026
d7e38ec
refactor: step-6 구현
mintcoke123 Jan 1, 2026
1c30a05
refactor: waitingResponse의 setter 추가(테스트코드용)
mintcoke123 Jan 1, 2026
a44bd1f
refactor: myReservationResponse에 jsonIgnore 적용
mintcoke123 Jan 1, 2026
739e9db
refactor: 사용하지 않는 메서드 삭제
mintcoke123 Jan 7, 2026
c05f17e
refactor: @getter 어노테이션 적용
mintcoke123 Jan 7, 2026
5b99e63
refactor: 미사용중인 import 문 제거
mintcoke123 Jan 7, 2026
68ae9f5
refactor: 에러코드 수정
mintcoke123 Jan 7, 2026
c8ef4c0
refactor: dto로 네이밍 변경
mintcoke123 Jan 7, 2026
26a581a
refactor: record 사용으로 수정
mintcoke123 Jan 8, 2026
0149b40
refactor: service 의존성 필드에 final 사용
mintcoke123 Jan 8, 2026
94fd21f
refactor: @transactional 적용
mintcoke123 Jan 8, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ out/

### VS Code ###
.vscode/


application-local.properties
5 changes: 4 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ 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'

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

Expand Down
30 changes: 30 additions & 0 deletions src/main/java/roomescape/WebConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package roomescape;

import org.springframework.beans.factory.annotation.Value;
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 roomescape.auth.AdminAuthInterceptor;
import roomescape.auth.LoginMemberArgumentResolver;

import java.util.List;

@Configuration
public class WebConfig implements WebMvcConfigurer {
@Value("${roomescape.auth.jwt.secret}")
private String secretKey;

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

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new AdminAuthInterceptor(secretKey))
.addPathPatterns("/admin", "/admin/**");
}
}


41 changes: 41 additions & 0 deletions src/main/java/roomescape/auth/AdminAuthInterceptor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package roomescape.auth;

import io.jsonwebtoken.Claims;
import jakarta.servlet.http.Cookie;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

안 쓰는 import문도 지워주는 것이 좋아요!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

수정했습니다!

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.servlet.HandlerInterceptor;
import roomescape.util.JwtUtil;

public class AdminAuthInterceptor implements HandlerInterceptor {

private final String secretKey;

public AdminAuthInterceptor(String secretKey) {
this.secretKey = secretKey;
}

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String token = JwtUtil.extractTokenFromCookies(request.getCookies());
if (token.isEmpty()) {
response.setStatus(401);
return false;
}

try {
Claims claims = JwtUtil.parseClaims(token, secretKey);
String role = claims.get("role", String.class);
if (!"ADMIN".equals(role)) {
response.setStatus(401);
return false;
}
return true;
} catch (Exception e) {
response.setStatus(401);
return false;
}
}
}
Comment on lines +20 to +51

@c0mpuTurtle c0mpuTurtle Jan 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

오류를 잘 걸러주셨어요 :)
다만 살짝 아쉬운 점은 문제상황에 같은 오류코드를 반환하고 있네요.

현재 토큰이 없는 경우와 권한이 없는 경우의 모든 실패 케이스에서 401을 반환하고 있는 데
이럴 때는 오류가 생겼을 때 오류의 원인이 모호할 수 있어요.

상황에 따라 상태코드를 분리해보는 건 어떨까요?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

또 PageController를 보아하니 동현님께서 응집화에 많이 신경쓰고 계신 거 같아 하나 더 알려 드리자면,
개인적으로는 에러 또한 메시지를 한 곳에서 함께 관리하는 편이 유지보수에도 더 좋고, 확장할 때도 안정적이라고 느껴졌어요.

그래서 error 관련 내용을 enum으로 통일해서 관리해보는 건 어떨까 조심스럽게 제안드립니다 😊

ex)

@Getter
@AllArgsConstructor
public enum FailMessage {

    //400
    BAD_REQUEST(HttpStatus.BAD_REQUEST, 40000, "잘못된 요청입니다."),
    BAD_REQUEST_REQUEST_BODY_VALID(HttpStatus.BAD_REQUEST, 40001, "잘못된 요청본문입니다."),
    BAD_REQUEST_MISSING_PARAM(HttpStatus.BAD_REQUEST, 40002, "필수 파라미터가 없습니다."),
    BAD_REQUEST_METHOD_ARGUMENT_TYPE(HttpStatus.BAD_REQUEST, 40003, "메서드 인자타입이 잘못되었습니다."),
    BAD_REQUEST_NOT_READABLE(HttpStatus.BAD_REQUEST, 40004, "Json 오류 혹은 요청본문 필드 오류 입니다. ");
private final HttpStatus httpStatus;
    private final int code;
    private final String message;
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

확실히 해당 방식이 유지보수성이 좋아보입니다! 이제 정적인 메세지는 enum으로 하드코딩할 생각을 먼저 하는 게 좋을 것 같습니다!
상태코드도 401,403으로 분리 완료했습니다!



44 changes: 44 additions & 0 deletions src/main/java/roomescape/auth/LoginMemberArgumentResolver.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package roomescape.auth;

import io.jsonwebtoken.Claims;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import roomescape.member.LoginMember;
import roomescape.util.JwtUtil;

public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {

private final String secretKey;

public LoginMemberArgumentResolver(String secretKey) {
this.secretKey = secretKey;
}

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

@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
String token = JwtUtil.extractTokenFromCookies(request.getCookies());

if (token.isEmpty()) {
return null;
}

Claims claims = JwtUtil.parseClaims(token, secretKey);

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

return new LoginMember(id, name, null, role);
}
}
33 changes: 33 additions & 0 deletions src/main/java/roomescape/member/LoginMember.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package roomescape.member;

public class LoginMember {
private Long id;
private String name;
private String email;
private String role;

@c0mpuTurtle c0mpuTurtle Jan 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

처음에는 이 객체가 어떤 역할을 하는 지 바로 와닿지 않았는 데, 쓰임을 보아하니 DTO 성격의 객체인 거 같습니다!

보통 DTO의 경우 LoginRequest처럼 클래스명에 Dto를 명시해 주는 경우가 많은데,
이렇게 하면 해당 객체가 엔티티인지, 전달용 객체인지를 빠르게 구분하기 쉽기때문이죠.

지금은 외부 api를 사용하는 프로젝트가 아니라서 필수는 아니지만,
그래도 이런 관례가 있다는 걸 알아두면 좋을 거 같아 남겨둡니다!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

저도 dto를 붙이는 편이 외부 api를 사용하지 않더라도 엔티티와 구별이 되어 좋다고 생각합니다!


public LoginMember(Long id, String name, String email, String role) {
this.id = id;
this.name = name;
this.email = email;
this.role = role;
}

public Long getId() {
return id;
}

public String getName() {
return name;
}

public String getEmail() {
return email;
}

public String getRole() {
return role;
}
}


9 changes: 9 additions & 0 deletions src/main/java/roomescape/member/Member.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
package roomescape.member;

import jakarta.persistence.*;

@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Column(unique = true)
private String email;
private String password;
private String role;

public Member() {
}

public Member(Long id, String name, String email, String role) {
this.id = id;
this.name = name;
Expand Down
62 changes: 62 additions & 0 deletions src/main/java/roomescape/member/MemberController.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package roomescape.member;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
Expand All @@ -14,6 +17,8 @@
@RestController
public class MemberController {
private MemberService memberService;
@Value("${roomescape.auth.jwt.secret}")
private String secretKey;

public MemberController(MemberService memberService) {
this.memberService = memberService;
Expand All @@ -25,6 +30,63 @@ public ResponseEntity createMember(@RequestBody MemberRequest memberRequest) {
return ResponseEntity.created(URI.create("/members/" + member.getId())).body(member);
}

@PostMapping("/login")
public ResponseEntity login(@RequestBody MemberRequest memberRequest, HttpServletResponse response) {
Member member = memberService.login(memberRequest.getEmail(), memberRequest.getPassword());

String accessToken = createToken(member);

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

return ResponseEntity.ok().build();
}

@GetMapping("/login/check")
public ResponseEntity<MemberResponse> checkLogin(HttpServletRequest request) {
String token = extractTokenFromCookie(request.getCookies());

String name = Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes()))
.build()
.parseClaimsJws(token)
.getBody()
.get("name", String.class);

MemberResponse body = new MemberResponse(null, name, null);
return ResponseEntity.ok(body);
}

public String createToken(Member member) {
return Jwts.builder()
.setSubject(member.getId().toString())
.claim("name", member.getName())
.claim("role", member.getRole())
.signWith(Keys.hmacShaKeyFor(secretKey.getBytes()))
.compact();
}


public String createTokenFromEmailAndPassword(String email, String password) {
Member member = memberService.login(email, password);
return createToken(member);
}


private String extractTokenFromCookie(Cookie[] cookies) {
if (cookies == null || cookies.length == 0) {
return "";
}
for (Cookie cookie : cookies) {
if ("token".equals(cookie.getName())) {
return cookie.getValue();
}
}
return "";
}

@c0mpuTurtle c0mpuTurtle Jan 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

(1) 동현님은 컨트롤러의 역할/책임을 어디까지로 보고 계신가요?

(2) 지금 컨트롤러에 토큰 생성/파싱/쿠키 추출 로직이 같이 들어가 있는데, 혹시 JwtUtil로 분리하지 않고 컨트롤러에 둔 이유가 있을까요?

@c0mpuTurtle c0mpuTurtle Jan 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

(3) 토큰 생성에 토큰 만료시간을 따로 설정 하지 않은 거 같은데요.
이 경우 토큰이 사실상 무기한 유효해질 수 있습니다. 이는 운영 관점에서 어떤 문제가 생길 수 있을까요?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

(1) 저는 저번 미션의 리뷰 과정에서, 컨트롤러의 역할을 다음과 같이 정의했습니다!

next-step/spring-roomescape-playground#533 (comment)

->

저는 컨트롤러의 정의대로, view(http)와 model(domain)을 연결하는 중간지점이라고 생각하여 view에서 RequestDto를 받아 model로 전달하고, 로직이 있다면 양 레이어에서 dto를 받아 필요한 정보만을 사용자에게 제공하는 핸들러 역할만을 수행한다고 생각했습니다.
따라서 @RestController, @controller만이 컨트롤러 클래스라고 생각했었습니다만, 멘토님의 질문을 받고 찾아본 결과 'view 표현을 위한 핸들링'의 관점에서는 멘토님께서 말씀해주신

HandlerMapping
@controller / @RestController
HttpMessageConverter
ViewResolver
DispatcherServlet
전체를 컨트롤러의 일부라고 볼 수 있을 것 같습니다.
그렇게 보면 컨트롤러가 표현에 필요한 DTO를 조립하는 것도 책임 범위 안에 들어갈 수 있을 것이라고 생각합니다!

만약 코드가 커져 표현에 필요한 DTO를 조립하게 된다면, 컨트롤러 단에서 조립하는 것이 좋은 방법이라고 생각합니다! 그러나 지금 제 코드의 컨트롤러는 비전형적인 요구사항이 추가되지 않는다면 TimeService만 호출하고 TimeResponse 그대로 리턴할 것이라고 생각합니다. 같은 비즈니스 로직을 쓰면서, 서로 다른 DTO로 응답해야 하는 컨트롤러를 지금 단계에서는 예측할 수 없다고 생각하기 때문에, 우선은 지금의 형태 그대로 코드를 유지하고자 합니다!
dto의 조립 위치에 대해서는 프로젝트의 성격에 맞춰 더 많은 고민이 필요할 것 같습니다..!

(2) jwtUtil로 메서드를 분리하는 리팩토링을 진행했었는데, 급하게 구현하다보니 생각을 못하고 이전에 하던대로 코딩을 진행했습니다! Jwtutil로 토큰의 생성/파싱/토큰 추출을 관리하도록 리팩토링을 진행했습니다!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

(3) 저번 스터디에서 말씀드린 것처럼 토큰이 한 번 유출되면 만료로 인해 자연스럽게 끊기지 않기 때문에, 공격자가 오랜 기간 해당 사용자 권한으로 접근할 수 있습니다.

토큰을 사용할 때는 만료시간을 정해놓는 건 필수일 것 같습니다!
createToken을 사용할 때 setExpiration을 사용하여 만료시간을 설정했습니다!


@PostMapping("/logout")
public ResponseEntity logout(HttpServletResponse response) {
Cookie cookie = new Cookie("token", "");
Expand Down
55 changes: 0 additions & 55 deletions src/main/java/roomescape/member/MemberDao.java

This file was deleted.

12 changes: 12 additions & 0 deletions src/main/java/roomescape/member/MemberRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package roomescape.member;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmailAndPassword(String email, String password);
Optional<Member> findByName(String name);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

사용하지 않는 메서드는 지워주는 것이 좋아요!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

이름으로 Member를 조회하는 기능이 필요하다고 착각하여 만들어놨는데 지우는 걸 깜빡했네요..!
수정했습니다!

}


12 changes: 8 additions & 4 deletions src/main/java/roomescape/member/MemberService.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@

@Service
public class MemberService {
private MemberDao memberDao;
private MemberRepository memberRepository;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Service 계층은 여러 요청이 동시에 접근할 수 있어서 가변 상태를 줄이는 것도 중요해요.
생성자 주입을 사용하고 있으니, 의존성 필드에 final을 붙여 불변으로 두는 건 어떨까요?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

좋은 생각입니다! 수정했습니다!


public MemberService(MemberDao memberDao) {
this.memberDao = memberDao;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}

public MemberResponse createMember(MemberRequest memberRequest) {
Member member = memberDao.save(new Member(memberRequest.getName(), memberRequest.getEmail(), memberRequest.getPassword(), "USER"));
Member member = memberRepository.save(new Member(memberRequest.getName(), memberRequest.getEmail(), memberRequest.getPassword(), "USER"));
return new MemberResponse(member.getId(), member.getName(), member.getEmail());
}

public Member login(String email, String password) {
return memberRepository.findByEmailAndPassword(email, password).orElseThrow();
}
}
Loading