프로젝트

(ProfitKey) 시큐리티 AOP인증 방식으로 MyPageController 코드 수정

민톨이 2025. 3. 13. 02:03
728x90

기존 MyPageController 코드를 보면

엔드포인트에 {userId}가 있고 직접 쿼리 파라미터 입력을 해서 인증을 하는 식이었는데, @AuthCheck와 시큐리티 AOP인증 방식으로 사용자 검증하는 방식으로 변경을 하였다.

 

결론: AOP(Aspect-Oriented Programming)와 시큐리티를 활용한 방식으로 코드 수정

 

🔧🔧변경 전 코드🔧🔧

 

기존 방식: ({userId}를 넣어서 사용자 체크하는 방식)

 

기존에는 엔드포인트에서 {userId}를 URL 경로 변수로 받아와서 해당 사용자에 대한 검증을 직접 수행하는 방식이었다.

 예를 들어, @GetMapping("/user/{userId}") 같은 방식으로 사용자가 요청할 때, URL에 포함된 userId를 추출해 데이터베이스에서 해당 사용자를 찾고 검증합니다. 이 방식은 다음과 같은 문제를 가질 수 있음

  • 코드 중복: 여러 곳에서 같은 사용자 검증 로직을 반복적으로 작성해야 할 수 있다
  • 코드 가독성 저하: 검증 로직이 엔드포인트마다 흩어져 있어, 코드 유지보수가 어려워질 수 있다
//닉네임 수정
	@PutMapping("/{userId}/nickname")
	@Operation(summary = SwaggerDocs.SUMMARY_UPDATE_NICKNAME, description = SwaggerDocs.DESCRIPTION_UPDATE_NICKNAME)
	public UserInfoResponse updateNickname(
		@PathVariable Long userId,
		@RequestParam String nickname
	) {
		return myPageService.updateNickname(userId, nickname);
	}

	//프로필 이미지 수정
	@PutMapping(value = "/{userId}/profile-image", consumes = "multipart/form-data", produces = "application/json")
	@Operation(
		summary = SwaggerDocs.SUMMARY_UPDATE_PROFILE_IMAGE,
		description = SwaggerDocs.DESCRIPTION_UPDATE_PROFILE_IMAGE,
		responses = {
			@ApiResponse(responseCode = "200", description = "성공적으로 프로필 사진이 수정되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserInfoResponse.class))),
			@ApiResponse(responseCode = "400", description = "파일 업로드 실패", content = @Content),
			@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없습니다.", content = @Content)
		}
	)
	public UserInfoResponse updateProfileImage(
		@PathVariable Long userId,
		@RequestPart("profileImage") MultipartFile profileImage
	) throws IOException {
		return myPageService.updateProfileImage(userId, profileImage);
	}

	//프로필 이미지 삭제
	@DeleteMapping("/{userId}/profile-image")
	@Operation(
		summary = SwaggerDocs.SUMMARY_DELETE_PROFILE_IMAGE,
		description = SwaggerDocs.DESCRIPTION_DELETE_PROFILE_IMAGE,
		responses = {
			@ApiResponse(responseCode = "200", description = "프로필 사진이 성공적으로 삭제되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserInfoResponse.class))),
			@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없습니다.", content = @Content)
		}
	)
	public UserInfoResponse deleteProfileImage(
		@PathVariable Long userId
	) {
		return myPageService.deleteProfileImage(userId);
	}

	// /me 엔드포인트 추가
	@Operation(summary = SwaggerDocs.SUMMARY_TOKEN_ME, description = SwaggerDocs.DESCRIPTION_TOKEN_ME)
	@GetMapping("/me")
	public ResponseEntity<Map<String, Object>> getUserInfo(HttpServletRequest request) {
		Map<String, Object> userInfo = authService.getUserInfoFromToken(request);
		return ResponseEntity.ok(userInfo);
	}

	//회원 탈퇴
	@DeleteMapping("/user/{userId}")
	@Operation(summary = SwaggerDocs.SUMMARY_DELETE_USER, description = SwaggerDocs.DESCRIPTION_DELETE_USER)
	public ResponseEntity<Void> deleteUser(@PathVariable Long userId) {
		myPageService.deleteUser(userId);
		return ResponseEntity.noContent().build();
	}

	/*
	 * 댓글 (community)
	 */
	@GetMapping("/comments/{userId}")
	@Operation(summary = SwaggerDocs.SUMMARY_USER_COMMENTS, description = SwaggerDocs.DESCRIPTION_USER_COMMENTS)
	public ResponseEntity<List<MyPageCommunityResponse>> getUserComments(@PathVariable Long userId) {
		List<MyPageCommunityResponse> comments = myPageService.getUserComments(userId);
		return ResponseEntity.ok(comments);
	}

	/*
	 * 관심 종목
	 */
	//관심 종목 추가
	@PostMapping("/{userId}/favorite-stocks")
	@Operation(summary = SwaggerDocs.SUMMARY_POST_FAVORITE_STOCKS, description = SwaggerDocs.DESCRIPTION_POST_FAVORITE_STOCKS)
	public ResponseEntity<Boolean> addFavoriteStock(@PathVariable Long userId,
		@RequestBody FavoriteStockRequest request) {
		boolean isLiked = myPageService.addFavoriteStock(userId, request.getStockCode());
		return ResponseEntity.ok(isLiked); // 찜 유무 반환
	}

	//관심 종목 상세 좋아요 유무 조회
	@GetMapping("/{userId}/favorite-stocks/{stockCode}")
	@Operation(summary = SwaggerDocs.SUMMARY_GET_FAVORITE_STOCKS, description = SwaggerDocs.DESCRIPTION_GET_FAVORITE_STOCKS)
	public ResponseEntity<Boolean> isFavoriteStock(
		@PathVariable @Parameter(description = "사용자 ID") Long userId,
		@PathVariable @Parameter(description = "찜 여부를 확인할 종목 코드") String stockCode
	) {
		boolean isLiked = myPageService.isFavoriteStock(userId, stockCode);
		return ResponseEntity.ok(isLiked);
	}

	//관심 종목 조회
	@GetMapping("/favorite-stocks/{userId}")
	@Operation(summary = SwaggerDocs.SUMMARY_FAVORITE_STOCKS, description = SwaggerDocs.DESCRIPTION_FAVORITE_STOCKS)
	public ResponseEntity<List<FavoriteStockResponse>> getFavoriteStocks(@PathVariable Long userId) {
		List<FavoriteStockResponse> response = myPageService.getFavoriteStocks(userId);
		return ResponseEntity.ok(response);
	}

	//관심 종목 삭제
	@DeleteMapping("/favorite-stocks/{userId}/{stockCode}")
	@Operation(summary = SwaggerDocs.SUMMARY_DELETE_FAVORITE_STOCK, description = SwaggerDocs.DESCRIPTION_DELETE_FAVORITE_STOCK)
	public ResponseEntity<Void> deleteFavoriteStock(@PathVariable Long userId, @PathVariable String stockCode) {
		myPageService.deleteFavoriteStock(userId, stockCode);
		return ResponseEntity.noContent().build();
	}

 

🔧🔧변경 후 코드🔧🔧

AOP 방식 (@AuthCheck + AOP)

AOP는 관심사의 분리를 통해 코드 중복을 줄이고, 핵심 비즈니스 로직과 공통적인 관심사를 분리하는 프로그래밍 패러다임.

AOP를 사용하면 특정 메서드가 실행될 때마다 공통적인 작업을 자동으로 실행할 수 있다

  • @AuthCheck: 사용자 인증/검증을 공통으로 처리하는 애너테이션을 정의하고, 이 애너테이션이 적용된 메서드에 대해 AOP로 인증 검증 로직을 처리하는 방식
  • 장점:
    • 중복 제거: 사용자 인증 및 검증 코드가 AOP를 통해 공통으로 처리되므로, 여러 엔드포인트에서 반복적으로 코드를 작성할 필요가 없음
    • 가독성 향상: 인증 및 검증 코드가 분리되어, 엔드포인트의 핵심 비즈니스 로직에 집중할 수 있음
    • 유지보수 용이: 인증 로직을 변경해야 할 경우, AOP 관련 코드만 수정하면 되므로 유지보수가 쉬워짐

===>> AOP란?

AOP는 프로그램의 실행 흐름에 개입하여 특정 작업을 자동으로 수행하게 해주는 개념임. 핵심 비즈니스 로직과는 별도로, 공통적인 관심사(예: 로깅, 보안, 트랜잭션 관리 등)를 처리하는데 유용하다

  • 핵심 개념:
    • Aspect: 공통된 관심사를 모아놓은 코드입니다. 예를 들어, 인증/권한 체크, 로깅 등이 이에 해당
    • Join Point: AOP가 적용될 수 있는 위치입니다. 메서드 실행, 메서드 호출 등이 될 수 있음
    • Advice: 실제로 실행되는 공통 관심사 코드입니다. 메서드 호출 전, 후, 예외 발생 시 등 다양한 시점에 실행할 수 있음
    • Pointcut: Advice를 적용할 Join Point를 정의하는 표현식

AOP를 사용하면 특정 메서드가 실행되기 전후 또는 예외 발생 시에 인증, 로깅, 트랜잭션 등의 공통 작업을 자동으로 처리할 수 있어 코드가 훨씬 깔끔해지고, 유지보수도 수월해짐

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class MyPageController {

	private final MyPageService myPageService;
	private final AuthService authService;
	private final FavoriteStockRepository favoriteStockRepository;

	/*
	 * 내 정보
	 */
	@GetMapping("/user")
	@AuthCheck
	@Operation(summary = SwaggerDocs.SUMMARY_USER_INFO, description = SwaggerDocs.DESCRIPTION_USER_INFO)
	public ResponseEntity<UserInfoResponse> getUserInfo() {
		UserInfoResponse response = myPageService.getUserInfo(SecurityUtil.getCurrentUserId());

		if (response != null) {
			return ResponseEntity.ok(response);
		} else {
			return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
		}
	}

	//닉네임 수정
	@PutMapping("/nickname")
	@AuthCheck
	@Operation(summary = SwaggerDocs.SUMMARY_UPDATE_NICKNAME, description = SwaggerDocs.DESCRIPTION_UPDATE_NICKNAME)
	public UserInfoResponse updateNickname(@RequestParam String nickname) {
		Long userId = SecurityUtil.getCurrentUserId();
		return myPageService.updateNickname(userId, nickname);
	}

	//프로필 이미지 수정
	@PutMapping(value = "/profile-image", consumes = "multipart/form-data", produces = "application/json")
	@AuthCheck
	@Operation(
		summary = SwaggerDocs.SUMMARY_UPDATE_PROFILE_IMAGE,
		description = SwaggerDocs.DESCRIPTION_UPDATE_PROFILE_IMAGE,
		responses = {
			@ApiResponse(responseCode = "200", description = "성공적으로 프로필 사진이 수정되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserInfoResponse.class))),
			@ApiResponse(responseCode = "400", description = "파일 업로드 실패", content = @Content),
			@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없습니다.", content = @Content)
		}
	)
	public UserInfoResponse updateProfileImage(@RequestPart("profileImage") MultipartFile profileImage) throws
		IOException {
		Long userId = SecurityUtil.getCurrentUserId();
		return myPageService.updateProfileImage(userId, profileImage);
	}

	//프로필 이미지 삭제
	@DeleteMapping("/profile-image")
	@AuthCheck
	@Operation(
		summary = SwaggerDocs.SUMMARY_DELETE_PROFILE_IMAGE,
		description = SwaggerDocs.DESCRIPTION_DELETE_PROFILE_IMAGE,
		responses = {
			@ApiResponse(responseCode = "200", description = "프로필 사진이 성공적으로 삭제되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserInfoResponse.class))),
			@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없습니다.", content = @Content)
		}
	)
	public UserInfoResponse deleteProfileImage() {
		Long userId = SecurityUtil.getCurrentUserId();
		return myPageService.deleteProfileImage(userId);
	}

	// /me 엔드포인트 추가
	@Operation(summary = SwaggerDocs.SUMMARY_TOKEN_ME, description = SwaggerDocs.DESCRIPTION_TOKEN_ME)
	@GetMapping("/me")
	public ResponseEntity<Map<String, Object>> getUserInfo(HttpServletRequest request) {
		Map<String, Object> userInfo = authService.getUserInfoFromToken(request);
		return ResponseEntity.ok(userInfo);
	}

	//회원 탈퇴
	@DeleteMapping("/user")
	@AuthCheck
	@Operation(summary = SwaggerDocs.SUMMARY_DELETE_USER, description = SwaggerDocs.DESCRIPTION_DELETE_USER)
	public ResponseEntity<Void> deleteUser() {
		Long userId = SecurityUtil.getCurrentUserId();
		myPageService.deleteUser(userId);
		return ResponseEntity.noContent().build();
	}

	/*
	 * 댓글 (community)
	 */
	@GetMapping("/comments")
	@AuthCheck
	@Operation(summary = SwaggerDocs.SUMMARY_USER_COMMENTS, description = SwaggerDocs.DESCRIPTION_USER_COMMENTS)
	public ResponseEntity<List<MyPageCommunityResponse>> getUserComments() {
		Long userId = SecurityUtil.getCurrentUserId();
		return ResponseEntity.ok(myPageService.getUserComments(userId));
	}

	/*
	 * 관심 종목
	 */
	//관심 종목 추가
	@PostMapping("/favorite-stocks")
	@AuthCheck
	@Operation(summary = SwaggerDocs.SUMMARY_POST_FAVORITE_STOCKS, description = SwaggerDocs.DESCRIPTION_POST_FAVORITE_STOCKS)
	public ResponseEntity<Boolean> addFavoriteStock(@RequestBody FavoriteStockRequest request) {
		Long userId = SecurityUtil.getCurrentUserId();
		return ResponseEntity.ok(myPageService.addFavoriteStock(userId, request.getStockCode()));
	}

	//관심 종목 상세 좋아요 유무 조회
	@GetMapping("/favorite-stocks/{stockCode}")
	@AuthCheck
	@Operation(summary = SwaggerDocs.SUMMARY_GET_FAVORITE_STOCKS, description = SwaggerDocs.DESCRIPTION_GET_FAVORITE_STOCKS)
	public ResponseEntity<Boolean> isFavoriteStock(@PathVariable String stockCode) {
		Long userId = SecurityUtil.getCurrentUserId();
		return ResponseEntity.ok(myPageService.isFavoriteStock(userId, stockCode));
	}

	//관심 종목 조회
	@GetMapping("/favorite-stocks")
	@AuthCheck
	@Operation(summary = SwaggerDocs.SUMMARY_FAVORITE_STOCKS, description = SwaggerDocs.DESCRIPTION_FAVORITE_STOCKS)
	public ResponseEntity<List<FavoriteStockResponse>> getFavoriteStocks() {
		Long userId = SecurityUtil.getCurrentUserId();
		return ResponseEntity.ok(myPageService.getFavoriteStocks(userId));
	}

	//관심 종목 삭제
	@DeleteMapping("/favorite-stocks/{stockCode}")
	@AuthCheck
	@Operation(summary = SwaggerDocs.SUMMARY_DELETE_FAVORITE_STOCK, description = SwaggerDocs.DESCRIPTION_DELETE_FAVORITE_STOCK)
	public ResponseEntity<Void> deleteFavoriteStock(@PathVariable String stockCode) {
		Long userId = SecurityUtil.getCurrentUserId();
		myPageService.deleteFavoriteStock(userId, stockCode);
		return ResponseEntity.noContent().build();
	}

}

 

SecurityUtil.java

public class SecurityUtil {
	public static Long getCurrentUserId() {
		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

		if (authentication == null || authentication.getPrincipal() == "anonymousUser") {
			throw new RuntimeException("로그인한 사용자가 아닙니다.");
		}

		return Long.parseLong(authentication.getName());
	}

}

 

AuthCheckAspect.java

@Aspect
@Component
public class AuthCheckAspect {

	@Around("@annotation(com.profitkey.stock.annotation.AuthCheck)")
	public Object validateUserId(ProceedingJoinPoint joinPoint) throws Throwable {
		Object[] args = joinPoint.getArgs();
		boolean hasUserIdProvider = false;

		for (Object arg : args) {
			if (arg instanceof UserIdProvider) {
				hasUserIdProvider = true;
				UserIdProvider request = (UserIdProvider)arg;
				Long authId = SecurityUtil.getCurrentUserId();
				Long requestUserId = Long.parseLong(request.getUserId());

				if (!authId.equals(requestUserId)) {
					throw new RuntimeException("사용자 인증 실패: 요청한 사용자 ID가 현재 로그인한 사용자와 일치하지 않습니다.");
				}
				break;
			}
		}

		// request 없을경우 로그인 정보만 확인
		if (!hasUserIdProvider) {
			Long authId = SecurityUtil.getCurrentUserId();
			if (authId == null) {
				throw new RuntimeException("로그인이 필요합니다.");
			}
		}
		return joinPoint.proceed();
	}
}

 

AuthCheck 인터페이스

package com.profitkey.stock.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthCheck {
}

 

스웨거에서 테스트 할 때에도 

 

토큰 사용하여 진행하면 된다!