🦁멋쟁이사자처럼 백엔드 부트캠프 13기 🦁
TIL 회고 - [66]일차
🚀64, 65, 66일차에는 모임 및 일정관리를 할 수 있는 프로젝트를 진행하였다.
학습 목표 : 모임 및 일정관리를 Swagger를 통해 테스트를 진행할 수 있고 권한에 따라 생성, 탈퇴가 가능해야함
학습 과정 : 회고를 통해 작성
설계
- 유저 → 모임 → 일정 3개의 엔티티로 크게 분류
- 개발의 우선순위 : User 엔티티
- 유저 관련
- User 엔티티 생성 : id, email, password
- UserRepository 생성 : 유저를 꺼낼때 Email을 기준으로 꺼내야함 (findByEmail 메소드)
- UserService 생성 : 회원수정 같은 기능은 명세되어있지 않으므로,
회원가입 /로그인/ 로그아웃 정도를 구현 register(), login(), logout()
register()의 구조
- 이메일이 이미 존재하는지 체크
- 1) 존재한다면 이미 존재하는 아이디 라고 리턴
if(userRepository.findByEmail(email).isPresent()){
return "이미 존재하는 아이디입니다."
}
- 2) 존재하지 않는다면 엔티티를 생성해서, 엔티티에 이메일, 패스워드 값을 넣음
User user = new User();
user.setEmail(email);
user.setPassword(password); // 패스워드 암호화 전
return userRepository.save(user);
🚀프로젝트 진행
프로젝트 예상 실행 흐름
- 사용자 요청: 클라이언트(웹 브라우저, 앱 등)에서 API 서버로 요청을 보냄
- DispatcherServlet: Spring MVC의 DispatcherServlet이 요청을 받아 Controller로 전달
- Controller: 요청 URL과 HTTP 메서드에 따라 해당하는 Controller의 메서드 실행
- Service: Controller는 Service를 호출하여 비즈니스 로직 처리
- Repository: Service는 Repository를 통해 데이터베이스와 상호작용
- Entity: Repository는 Entity를 사용하여 데이터베이스의 데이터를 객체로 매핑
- 응답: Service는 처리 결과를 Controller로 반환 후 Controller가 클라이언트에게 HTTP 응답을 보냄
- 클라이언트: 클라이언트는 HTTP 응답을 받아 사용자에게 결과를 표시
[클라이언트] --> [DispatcherServlet] --> [Controller] --> [Service] --> [Repository] --> [Entity] --> [데이터베이스]
AuthController
1) 회원가입
// 로그인/회원가입/로그아웃/User관리
@RestController
@RequestMapping("/auth")
@Tag(name = "Authentication", description = "인증 관련 API")
@RequiredArgsConstructor
public class AuthController {
private final UserService userService;
private final JwtUtil jwtUtil;
@Operation(
summary = "회원가입",
description = "이메일과 비밀번호를 입력하여 회원가입을 합니다.",
tags = {"Authentication"}
)
@PostMapping("/register")
public ResponseEntity<String> register(@RequestBody RegisterRequestDto requestDto){
try {
userService.register(requestDto.getEmail(), requestDto.getPassword());
return ResponseEntity.ok("회원가입 성공!");
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
}
}
//... 나머지 로직
- @RestController : 이 클래스가 Rest API의 컨트롤러임을 명시
클라이언트와 서버 간 통신을 HTTP 프로토콜을 사용하여 처리하고
각 메소드의 반환 값을 자동으로 JSON형식으로 변환하여 클라이언트에게 전달 가능 - @Tag
➡️Swagger문서에서 API를 그룹화하여 설명하기 위한 어노테이션
API 문서의 가독성을 높이고 개발자가 API를 쉽게 이해하기 위함 - @PostMapping(”/register”)
/auth/register 경로로 POST 요청이 들어오면 register() 메소드가 실행됨을 명시
POST 요청은 클라이언트가 서버에 데이터를 “생성하거나 수정하도록 요청”할때 사용
즉 회원가입 같은 경우 “새로운 사용자 정보를 서버에 생성하는 작업”이므로 POST 요청을 사용 - public ResponseEntity<String> register(@RequestBody RegisterRequestDto requestDto)
클라이언트로부터 RegisterRequestDto 객체를 받아 회원가입을 처리하고 결과를 ResponseEntity로 반환하는 메소드 - @RequestBody :
➡️클라이언트가 요청 본문에 포함시킨 JSON 데이터를 RegisterRequestDto 객체로 변환하여
메소드의 파라미터로 전달
2) 로그인 관리
@Operation(summary = "로그인", description = "사용자가 모임 관리 서비스에 로그인합니다.")
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequestDto requestDto){
String token = userService.login(requestDto.getEmail(), requestDto.getPassword());
if (token == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("이메일 또는 비밀번호가 올바르지 않습니다.");
}
return ResponseEntity.ok(Collections.singletonMap("token", token));
}
- public ResponseEntity<?> login(@RequestBody LoginRequestDto requestDto)
➡️클라이언트로부터 LoginRequestDto 객체를 받아 로그인을 처리하고, 결과를 ResponseEntity로 반환 - ResponseEntity<?> ➡️다양한 타입의 응답을 처리할 수 있도록 제네릭 타입으로 선언
- String token = userService.login(requestDto.getEmail(), requestDto.getPassword());
UserService의 login()메소드 호출로 로그인을 처리한 후 JWT 토큰을 반환받아 token에 저장
만약 null을 반환하면 로그인 실패로 간주하여 오류 응답 생성 - return ResponseEntity.ok(Collections.singletonMap("token", token));
로그인 성공을 하게되면 JWT 토큰을 JSON 형식으로 클라이언트에게 반환 - Collections.singletonMap()➡️하나의 키-값 쌍의 Map 구조를 생성하게됨
3) 로그아웃 관리
@PostMapping("/logout")
public ResponseEntity<String> logout(
@Parameter(description = "JWT 인증 토큰", required = true, example = "Bearer eyJhbGciOiJIUzI....")
@RequestHeader("Authorization") String token
){...}
- @Parameter : Swagger 문서에서 파라미터에 대한 설명, 필수 여부, 예시 등을 제공
- @RequestHeader(”Authorization”) String token ➡️Authorization 헤더에서 JWT 토큰을 추출
- if(token.startsWith("Bearer ")){ token = token.substring(7); }
➡️“Bearer “ 접두사를 제거하여 JWT 토큰만 추출하도록 함 - jwtUtil.invalidateToken(token);
➡️로그아웃 되었으면 이 토큰을 무효화해야하므로 invalidateToken()메소드로써 token을 무효화
MeetingController
- 모임관련 요청
1) 모임 생성
@PostMapping
public ResponseEntity<Meeting> createMeeting(
@RequestHeader("Authorization") String token,
@RequestBody Meeting meeting) {
Long userId = jwtUtil.validateToken(token.replace("Bearer ", ""));
if (userId == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
User user = userService.findUserById(userId);
meeting.setCreatedBy(user); // 모임 생성자 지정
Meeting savedMeeting = meetingService.createMeeting(meeting);
return ResponseEntity.ok(savedMeeting);
}
- public ResponseEntity<Meeting> createMeeting(...)
ResponseEntity<Meeting>은 HTTP 응답을 나타내며, Meeting 객체를 응답 본문에 포함 - @RequestHeader("Authorization") String token
Authorization 헤더에서 JWT 토큰을 추출하는데 이는 클라이언트가 인증된 사용자인지 확인하기 위해 토큰을 사용 - Long userId = jwtUtil.validateToken(token.replace("Bearer ", ""));
JWT 토큰의 유효성을 검증하고, 토큰에서 사용자 ID를 추출
jwtUtil.validateToken() 메서드는 JWT 토큰을 검증하고, 토큰에서 사용자 ID(userId)를 추출
즉, 토큰 자체를 반환이 아닌 토큰의 내용을 분석하여 추출된 사용자 ID를 반환 - meeting.setCreatedBy(user);
Meeting 엔티티에서 정의했던 createdBy (User타입) 필드를 통해 생성된 User 객체를 모임의 생성자로 설정함 - Meeting savedMeeting = meetingService.createMeeting(meeting);
MeetingService를 사용하여 모임을 생성하고, 생성된 모임 객체를 반환 - return ResponseEntity.ok(savedMeeting);
생성된 모임 객체(savedMeeting)와 200 OK 상태 코드를 클라이언트에게 반환
2) 모임 참가
- joinMeeting()
- if (userId == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).
body("유효하지 않은 토큰입니다."); }
➡️토큰이 유효하지 않으면 401 Unauthorized 상태 코드를 반환 - meetingService.joinMeeting(meeting, user); ➡️MeetingService를 사용하여 사용자를 모임에 참가
3) 모임 수정
- Meeting meeting = meetingService.updateMeeting(meetingId, updatedMeeting, user);
MeetingService를 사용하여 모임을 수정하고, 수정된 모임 객체를 반환
4) 모임 삭제
- public ResponseEntity<Void> deleteMeeting(...)
모임 삭제 요청을 처리하고, 성공적인 삭제를 나타내는 ResponseEntity를 반환 - meetingService.deleteMeeting(meetingId, user);
MeetingService를 사용하여 모임을 삭제 - return ResponseEntity.ok().build();
삭제 성공을 나타내는 200 OK 상태 코드와 빈 응답 본문을 클라이언트에게 반환
ScheduleController
1) 일정 생성
- if (meeting == null) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null); }
모임이 존재하지 않으면 404 Not Found 상태 코드와 빈 응답 본문을 반환
body(null): 응답 본문을 비워서 클라이언트에게 모임이 없음을 알림
- if (!participants.contains(user)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null); }
➡️사용자가 모임 참가자가 아니면 403 Forbidden 상태 코드와 빈 응답 본문을 반환 - schedule.setMeeting(meeting);: 일정에 모임 정보를 설정
- schedule.setCreatedBy(user);: 일정 생성자를 설정
2) 일정 수정
- if (userId == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid token"); }
➡️토큰이 유효하지 않으면 401 Unauthorized 상태 코드와 오류 메시지를 반환 - ScheduleStatus newStatus = ScheduleStatus.valueOf(request.get("status").toUpperCase());
➡️요청 본문에서 추출한 상태 정보를 ScheduleStatus enum으로 변환
- valueOf(): 문자열을 enum 상수로 변환하는 메소드
요청 본문의 "status" 값을 대문자로 변환하여 ScheduleStatus enum의 상수와 비교
❓valueOf() 메서드 사용 이유
➡️valueOf() 메서드는 문자열을 enum 상수로 변환하는 데 사용되는데 요청 본문의 "status" 값은 문자열 형태이므로
이를 ScheduleStatus enum의 상수로 변환해야함
valueOf() 메서드는 문자열을 대문자로 변환하여 enum 상수와 비교하므로,
대소문자 구분 없이 enum 상수를 찾을 수 있음
ScheduleMember 엔티티
@Entity
@Getter@Setter
public class ScheduleMember {
//... 이외 필드들
@Enumerated(EnumType.STRING) // Enum -> String 가능 (=DB에서 문자열(VARCHAR)로 저장됨)
@Column(nullable = false)
private ScheduleStatus status = ScheduleStatus.ATTENDING; // 기본값 : ATTENDING으로 부여
}
- @Enumerated 어노테이션
➡️Java의 enum 타입을 데이터베이스에 매핑할 때 사용
enum 타입은 기본적으로 데이터베이스에 저장할 수 없으므로,
@Enumerated 어노테이션을 사용하여 enum 값을 어떤 방식으로 데이터베이스에 저장할지 지정 - @Enumerated 어노테이션의 2가지 옵션 >> EnumType.STRING / EnumType.ORDINAL
➡️EnumType.ORDINAL : enum의 순서 값을 데이터베이스에 저장
ex. ATTENDING, MAYBE 순서이면 ATTENDING은 0, MAYBE는 1로 저장
➡️EnumType.STRING : enum의 이름을 문자열로 데이터베이스에 저장
ex. ATTENDING은 "ATTENDING", MAYBE는 "MAYBE"로 저장
즉 @Enumerated 어노테이션 없이는 enum 타입을 데이터베이스에 매핑할 수 없음
DTO 사용의 장점
- 엔티티는 데이터베이스 테이블과 직접 매핑되므로 민감한 정보를 포함할 수도 있는데
DTO를 사용하면 클라이언트에게 필요한 정보만 선택적으로 전달하여 보안을 강화할 수 있음 - Swagger 문서화
➡️@Schema 어노테이션을 사용하여 DTO에 대한 설명을 추가하면 Swagger 문서에서 API를 명확하게 설명 가능
MeetingMemberRepository
public interface MeetingMemberRepository extends JpaRepository<MeetingMember, Long> {
List<MeetingMember> findByMeeting(Meeting meeting); // 특정 Meeting에 속한 모든 회원 조회
List<MeetingMember> findByUser(User user); // 특정 User가 참여중인 모든 모임 조회
boolean existsByMeetingAndUser(Meeting meeting, User user); // 모임 및 사용자가 존재하는지 true/false
Optional<MeetingMember> findByMeetingAndUser(Meeting meeting, User user); // 모임정보를 검색
int countByMeeting(Meeting meeting); // 현재 참가자 수 계산
}
❓countByMeeting()메소드의 반환타입이 int 타입인 이유
➡️countByMeeting() 메서드는 항상 0 이상의 정수 값을 반환하므로 null 값을 반환할 필요가 없음
(=따라서 Integer 타입을 사용할 필요가 없음)
MeetingService
@Service
@RequiredArgsConstructor
public class MeetingService {
private final MeetingRepository meetingRepository;
private final MeetingMemberRepository meetingMemberRepository;
// ... 이 외 메소드들
// 모임 삭제 (DELETE)
@Transactional
public void deleteMeeting(Long meetingId, User currentUser) { // 모임의 id로 검색
Meeting meeting = meetingRepository.findById(meetingId)
.orElseThrow(() -> new IllegalArgumentException("모임을 찾을 수 없습니다."));
if (!meeting.getCreatedBy().equals(currentUser)) { // 모임의 생성자가 아닐 경우 삭제 불가 -> 예외 발생
throw new SecurityException("이 모임의 생성자가 아닙니다.");
}
meetingRepository.delete(meeting);
}
//...
- Meeting meeting = meetingRepository.findById(meetingId).orElseThrow(() -> new IllegalArgumentException("모임을 찾을 수 없습니다."));
➡️meetingId로 모임을 조회하고, 모임이 없으면 IllegalArgumentException을 발생
➡️orElseThrow(): Optional 객체가 비어있을 경우 지정된 예외를 발생 - if (!meeting.getCreatedBy().equals(currentUser)) { throw new SecurityException
("이 모임의 생성자가 아닙니다."); }
➡️현재 사용자가 모임 생성자가 아니면 SecurityException을 발생
➡️SecurityException: 보안 관련 예외를 나타내는 예외 클래스
이 경우, 모임 삭제 권한이 없는 사용자가 삭제를 시도했음을 알림
// 모임 목록 조회 (=사용자 생성/참여한 모임)
@Transactional(readOnly = true)
public List<Meeting> findAllMeetingsByUser(User user) {
return meetingMemberRepository.findByUser(user)
.stream()
.map(MeetingMember::getMeeting)
.collect(Collectors.toList());
}
- @Transactional(readOnly = true) ➡️읽기 전용 트랜잭션으로 설정하여 데이터베이스 성능을 향상
- .stream()
➡️meetingMemberRepository.findByUser(user)가 반환한 List<MeetingMember>를 스트림으로 변환 - .map(MeetingMember::getMeeting)
➡️스트림의 각 MeetingMember 객체에서 Meeting 객체를 추출
➡️메서드 레퍼런스를 사용
(=람다 표현식 meetingMember -> meetingMember.getMeeting()) 같은 역할
UserService
// 로그인
public String login(String email, String password) {
Optional<User> user = userRepository.findByEmail(email);
if (user.isPresent() && passwordEncoder.matches(password, user.get().getPassword())) { // 비밀번호 암호화
return jwtUtil.generateToken(user.get().getId()); // 토큰 발급
}
return null;
}
// 사용자 인증 (=JWT 토큰 검증)
public Long validateToken(String token) {
return jwtUtil.validateToken(token);
}
// 로그아웃
public void logout(String token) {
jwtUtil.invalidateToken(token);
}
- Optional<User> user = userRepository.findByEmail(email);
➡️Optional은 조회 결과가 null일 수 있음을 나타냄 - if (user.isPresent() && passwordEncoder.matches(password, user.get().getPassword()))
➡️user.isPresent(): 사용자가 존재하는지 확인
- passwordEncoder.matches(password, user.get().getPassword())
입력받은 password와 데이터베이스에 저장된 암호화된 비밀번호를 비교 - return jwtUtil.generateToken(user.get().getId());
➡️로그인 성공 시 jwtUtil을 사용하여 JWT 토큰을 생성하고 반환 - user.get().getId(): user 객체에서 사용자 ID를 추출
➡️user.get()은 Optional 객체에서 User 객체를 가져오는 메소드
➡️getId()는 User 객체에서 ID를 가져오는 메소드
즉, 데이터베이스에서 가져온 사용자 객체에서 id를 추출하는 것 - return null;: 로그인 실패 시 null을 반환
- return jwtUtil.validateToken(token);
➡️jwtUtil을 사용하여 주어진 token의 유효성을 검증하고, 토큰에서 사용자 ID를 추출하여 반환 - jwtUtil.invalidateToken(token);
➡️ jwtUtil을 사용하여 주어진 token을 무효화
(= 로그아웃 시 토큰을 블랙리스트에 추가하거나 만료 시간을 설정하여 무효화시킴)
JwtUtil
- 토큰 관련 클래스
@Component
public class JwtUtil {
private static final String SECRET = "12345678901234567890123456789012"; // 비밀키
private static final long EXPIRATION_TIME = 1000 * 60 * 60; // 토큰의 유효시간 1시간
private final ConcurrentHashMap<String, Boolean> invalidTokens = new ConcurrentHashMap<>();
public String generateToken(Long userId) {
return JWT.create()
.withSubject(String.valueOf(userId))
.withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.sign(Algorithm.HMAC256(SECRET));
}
public Long validateToken(String token) {
try {
if (invalidTokens.containsKey(token)) {
return null; // 로그아웃된 토큰을 처리함
}
DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC256(SECRET)).build().verify(token);
return Long.parseLong(decodedJWT.getSubject()); // 사용자 ID를 반환
} catch (JWTVerificationException | NumberFormatException e) {
return null; // 유효하지 않은 토큰
}
}
public void invalidateToken(String token) {
invalidTokens.put(token, true);
}
}
- private static final String SECRET = "my-secret-key";
➡️ JWT 토큰을 생성하고 검증하는 데 사용되는 비밀키 - private final ConcurrentHashMap<String, Boolean> invalidTokens = new ConcurrentHashMap<>();
로그아웃된 토큰을 저장하는 ConcurrentHashMap으로
ConcurrentHashMap은 여러 스레드에서 동시에 접근해도 안전한 HashMap 구조
- JWT.create(): JWT 토큰을 생성하는 빌더를 생성
- .withSubject(String.valueOf(userId)): 토큰의 주제로 사용자 ID를 설정
- .withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME)): 토큰의 만료 시간을 설정
현재 시간에서 EXPIRATION_TIME만큼 더한 시간을 만료 시간으로 설정
(=현재시간으로부터 N초 후 만료되도록 설정하는 것) - .sign(Algorithm.HMAC256(SECRET)): 주어진 SECRET 키를 사용하여 HMAC256 알고리즘으로 토큰에 서명
- if (invalidTokens.containsKey(token)) { return null; }
➡️로그아웃된 토큰인지 확인
➡️ invalidTokens 맵에 토큰이 존재하면 null을 반환 - DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC256(SECRET)).build().verify(token);
➡️주어진 SECRET 키를 사용하여 HMAC256 알고리즘으로 토큰의 서명을 검증하고, 토큰을 디코딩 - return Long.parseLong(decodedJWT.getSubject());
➡️디코딩된 토큰에서 subject(사용자 ID)를 추출하여 반환 - catch (JWTVerificationException | NumberFormatException e)
➡️토큰 검증 실패 또는 subject가 숫자가 아닌 경우 null을 반환 - public void invalidateToken(String token)
invalidTokens 맵에 토큰을 추가하여 로그아웃된 토큰(=무효화 처리)으로 처리하는 것
API 테스트
회원가입 API 테스트
- 데이터베이스 상에도 BODY를 통해 보낸 데이터가 잘 저장된 것을 확인할 수 있음 (비밀번호 암호화도 수행)
로그아웃 테스트
- 1) localhost:8080/auth/logout을 호출
- 2) Spring Security를 사용하고 있다면, 세션 기반 인증을 사용
- 3) 로그아웃 시 서버는 세션을 무효화하고 JSESSIONID 쿠키를 삭제
- 4) 따라서 클라이언트는 더 이상 유효한 세션을 가지지 않게 됨
- 5) Access Token을 활용하면 클라이언트 측에서 관리되어 로그아웃을 하여도 서버는 Access Token을 삭제하지 않
- 6) 하지만 서버는 로그아웃된 토큰을 무효화하여 더 이상 사용할 수 없도록 처리하므로
클라이언트는 로그아웃 후 로컬 스토리지나 쿠키에서 Access Token을 삭제해야함 (=토큰 재활용 불가)
모임 관련 API 테스트
모임 생성
모임 참가
일정 관련 API 테스트
일정 생성
일정 참가
Swagger - status 변경
- localhost:8080/meetings/{meetingId}/schedules/{scheduleId}/status 호출
- 기존 User의 참석 상태 ➡️1번 일정이 "참석중 (ATTENDING)" 으로 표시되어있다.
- PATCH 수행 후 MAYBE로 상태가 변경되었다.
🚀회고 결과 :
정신없었던 프로젝트를 완료할 수 있었다. 각 메소드 구현과 @GetMapping, @PostMapping 등으로 호출하는 과정에서 API를 테스트하는 것이 가장 어려웠다. 토큰 값과 Security 적용을 하는 과정에서도 어려움이 많았는데 이전에 진행했던 회고들을 통해 완성해낼 수 있었다.
향후 계획 :
- 정리한 회고 다시 숙달
'Recording > 멋쟁이사자처럼 BE 13기' 카테고리의 다른 글
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_68일차_"Git / GitHub" (0) | 2025.03.18 |
---|---|
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_67일차_"JUnit" (0) | 2025.03.17 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_63일차_"Swagger" (0) | 2025.03.11 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_62일차_"OAuth2 연동" (0) | 2025.03.10 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_61일차_"Spring Security 예외" (0) | 2025.03.07 |