💻Today's Schedule
09:00 ~ 10:00 CODEKATA
10:00 ~ 11:30 개인 학습
11:30 ~ 12:00 팀미팅
12:00 ~ 13:00 점심 식사
13:00 ~ 14:00 개인 학습
14:00 ~ 16:00 스탠다드 이론반 수강
16:00 ~ 18:00 개인학
18:00 ~ 19:00 저녁 식사
19:00 ~ 21:00 개인 학습
✍ Today I Learned
CODEKATA
- 알고리즘 (71번 개인정보 수집 유효기간)
[프로그래머스/JAVA] 개인정보 수집 유효기간
문제 설명 고객의 약관 동의를 얻어서 수집된 1~n번으로 분류되는 개인정보 n개가 있습니다. 약관 종류는 여러 가지 있으며 각 약관마다 개인정보 보관 유효기간이 정해져 있습니다. 당신은 각
fargoewave.tistory.com
개인과제 Level 1-1 @Transactional의 이해
문제
할 일 저장 기능을 구현한 API(/todos)를 호출할 때, 아래와 같은 에러가 발생하고 있어요.
jakarta.servlet.ServletException: Request processing failed: org.springframework.orm.jpa.JpaSystemException: could not execute statement [Connection is read-only. Queries leading to data modification are not allowed] [insert into todos (contents,created_at,modified_at,title,user_id,weather) values (?,?,?,?,?,?)]
에러가 발생하지 않고 정상적으로 할 일을 저장 할 수 있도록 코드를 수정해주세요.
@RestController
@RequiredArgsConstructor
public class TodoController {
private final TodoService todoService;
@PostMapping("/todos")
public ResponseEntity<TodoSaveResponse> saveTodo(
)
...
}
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TodoService {
private final TodoRepository todoRepository;
private final WeatherClient weatherClient;
public TodoSaveResponse saveTodo(AuthUser authUser, TodoSaveRequest todoSaveRequest) {
...
}
}
풀이
- TodoService에서 saveTodo 메서드 실행 중에 Connection is read-only. Queries leading to data modification are not allowed라는 오류가 발생했고, 이 문제는 JPA에서 데이터베이스에 데이터를 삽입하려고 할 때 트렌잭션이 readOnly 즉 읽기 전용으로 설정되어 있어 발생하고 있다.
- 서비스로직에서 @Transactional(readOnly = true) readOnly가 클래스 레벨에 적용되어 있는 것을 확인했고, 모든 메서드가 기본적으로 읽기 전용 트랜젝션 안에서 실행되고 있었다. saveTodo는 데이터를 삽입해야 하는 메서드이기 때문에 에러가 발생했다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TodoService {
private final TodoRepository todoRepository;
private final WeatherClient weatherClient;
@Transactional
public TodoSaveResponse saveTodo(AuthUser authUser, TodoSaveRequest todoSaveRequest) {
User user = User.fromAuthUser(authUser);
- saveTodo 메서드에 별도로 @Transactional 어노테이션을 추가해, 쓰기 가능한 트랜잭션으로 설정하여 메서드 단위에서 트랜잭션 동작을 재정의했다.
readOnly = true를 사용하지 않으면 발생할 수 있는 성능 저하는 무엇인가?
- @Transactional(readOnly = true)를 사용하지 않으면, 읽기 전용 작업에도 불필요한 트랜잭션 비용이 발생할 수 있어 성능 저하가 발생할 수 있다.
- 데이터 수정이 없는 경우에도 트랜잭션 로그가 작성될 수 있다. 트랜젝션 로그는 데이터 변경 시 복구를 위해 기록되는데, 읽기 전용 작업에서는 불필요한 작업이다. 이를 방지하려면 readOnly =true 로 설정해야 한다.
- 데이터베이스는 트랜젝션이 시작되면 데이터를 잠글 수 있는데, readOnly=true가 없으면 쓰기 트랜잭션과 동일한 잠금 전략이 적용될 수 있다. 이는 lock contention을 유발해 성능 저하로 이어질 수 있다.
- 쓰기 권한이 필요하지 않은 작업에서도 쓰기 가능한 세션을 생성하게 되면, 데이터베이스와의 불필요한 오버헤드가 발생한다.
개인과제 Level 1-2 JWT의 이해
문제
아래의 요구사항에 맞춰 기획 요건에 대응할 수 있는 코드를 작성해주세요.
- User의 정보에 nickname이 필요해졌어요.
- User 테이블에 nickname 컬럼을 추가해주세요.
- nickname은 중복 가능합니다.
- 프론트엔드 개발자가 JWT에서 유저의 닉네임을 꺼내 화면에 보여주길 원하고 있어요.
- 요구사항이 따라 User 엔티티에 nickname 필드를 추가했다.
- JWT 토큰에 유저 nickname을 포함하고, 이를 통해 프론트엔드에서 닉네임을 가져와 사용할 수 있도록 했다.
풀이1. User 엔티티에 닉네임 필드 추가
- 닉네임을 저장할 수 있는 필드를 추가했다.
- 중복이 가능하도록 @Column(unique = true)은 추가하지 않았다.
@Getter
@Entity
@NoArgsConstructor
@Table(name = "users")
public class User extends Timestamped {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String email;
private String password;
@Enumerated(EnumType.STRING)
private UserRole userRole;
private String nickname; // 1-2: 닉네임 추가
public User(String email, String password, String nickname, UserRole userRole) {
this.email = email;
this.password = password;
this.nickname = nickname;
this.userRole = userRole;
}
// 기타 메서드...
}
풀이2. AuthUser 클래스에 닉네임 필드 추가
- JWT에서 AuthUser 객체로 유저 정보를 다룰 때, 닉네임 정보를 포함하도록 AuthUser 클래스에도 nickname 필드를 추가하고, 생성자에 이를 반영했다.
public class AuthUser {
private final Long id;
private final String email;
private final String nickname; // 1-2: 닉네임 필드 추가
private final UserRole userRole;
public AuthUser(Long id, String email, String nickname, UserRole userRole) {
this.id = id;
this.email = email;
this.nickname = nickname;
this.userRole = userRole;
}
}
풀이3, JwtUtil 클래스 수정
- JWT 토큰을 생성할 때 닉네임을 포함하도록 JwtUtil 클래스를 수정했다.
public String createToken(Long userId, String email, String nickname, UserRole userRole) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("email", email)
.claim("nickname", nickname) // 닉네임 추가
.claim("userRole", userRole)
.setExpiration(new Date(date.getTime() + TOKEN_TIME))
.setIssuedAt(date)
.signWith(key, signatureAlgorithm)
.compact();
}
풀이4. JwtFilter 수정
- JWT 필터에서 토큰을 해석하고 유저 정보를 추출할 때, 닉네임도 함께 추출할 수 있도록 수정했다.
public class JwtFilter implements Filter {
private final JwtUtil jwtUtil;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String bearerJwt = httpRequest.getHeader("Authorization");
String jwt = jwtUtil.substringToken(bearerJwt);
Claims claims = jwtUtil.extractClaims(jwt);
Long userId = Long.parseLong(claims.getSubject());
String email = claims.get("email", String.class);
String nickname = claims.get("nickname", String.class); // 닉네임 추가
String userRoleStr = claims.get("userRole", String.class);
httpRequest.setAttribute("userId", userId);
httpRequest.setAttribute("email", email);
httpRequest.setAttribute("nickname", nickname); // 닉네임 추가
httpRequest.setAttribute("userRole", userRoleStr);
chain.doFilter(request, response);
}
}
풀이5. AuthUserArgumentResolver 수정
- JWT 토큰에서 닉네임을 추출하여 AuthUser객체에 전달하도록 AuthUserArgumentresolver 클래스를 수정했다.
public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
Long userId = (Long) request.getAttribute("userId");
String email = (String) request.getAttribute("email");
String nickname = (String) request.getAttribute("nickname"); // 닉네임 추가
UserRole userRole = UserRole.of((String) request.getAttribute("userRole"));
return new AuthUser(userId, email, nickname, userRole);
}
}
풀이6. AuthService 수정
- AuthService에서 회원가입과 로그인 시 JWT 토큰을 생성할 때 닉네임을 포함하도록 코드를 수정했다.
@Transactional
public SignupResponse signup(SignupRequest signupRequest) {
if (userRepository.existsByEmail(signupRequest.getEmail())) {
throw new InvalidRequestException("이미 존재하는 이메일입니다.");
}
String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());
UserRole userRole = UserRole.of(signupRequest.getUserRole());
User newUser = new User(
signupRequest.getEmail(),
encodedPassword,
signupRequest.getNickname(),
userRole
);
User savedUser = userRepository.save(newUser);
String bearerToken = jwtUtil.createToken(savedUser.getId(), savedUser.getEmail(), savedUser.getNickname(), userRole);
return new SignupResponse(bearerToken);
}
public SigninResponse signin(SigninRequest signinRequest) {
User user = userRepository.findByEmail(signinRequest.getEmail()).orElseThrow(
() -> new InvalidRequestException("가입되지 않은 유저입니다."));
if (!passwordEncoder.matches(signinRequest.getPassword(), user.getPassword())) {
throw new AuthException("잘못된 비밀번호입니다.");
}
String bearerToken = jwtUtil.createToken(user.getId(), user.getEmail(), user.getNickname(), user.getUserRole());
return new SigninResponse(bearerToken);
}
개인과제 Level 1-3 AOP의 이해
문제
AOP가 잘못 동작하고 있어요!
- UserAdminController 클래스의 changeUserRole() 메소드가 실행 전 동작해야해요.
- AdminAccessLoggingAspect 클래스에 있는 AOP가 개발 의도에 맞도록 코드를 수정해주세요.
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class AdminAccessLoggingAspect {
private final HttpServletRequest request;
@After("execution(* org.example.expert.domain.user.controller.UserController.getUser(..))")
public void logAfterChangeUserRole(JoinPoint joinPoint) {
String userId = String.valueOf(request.getAttribute("userId"));
String requestUrl = request.getRequestURI();
LocalDateTime requestTime = LocalDateTime.now();
log.info("Admin Access Log - User ID: {}, Request Time: {}, Request URL: {}, Method: {}",
userId, requestTime, requestUrl, joinPoint.getSignature().getName());
}
}
풀이
- AdminAccessLoggingAspect 클래스에서 @After 어노테이션을 사용했지만, changeUserRole() 메서드 실행 전에 로그를 남기도록 수정해야하기 때문에 @After 대신 @Before 어노테이션로 변경하고, UserController.getUser(..) 메서드를 대상으로 하고 있기 때문에, 이를 UserAdminController.changeUserRole(..) 메서드로 변경했다.
- @Before는 메서드 호출 직전에 실행되어 메서드 실행 전의 시간을 기록하며, @After는 메서드가 정상 종료, 예외 발생에 상관 없이 메서드 완료 후에 실행되어 실행이 끝난 후의 시간을 기록한다.
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class AdminAccessLoggingAspect {
private final HttpServletRequest request;
@Before("execution(* org.example.expert.domain.user.controller.UserAdminController.changeUserRole(..))")
public void logAfterChangeUserRole(JoinPoint joinPoint) {
String userId = String.valueOf(request.getAttribute("userId"));
String requestUrl = request.getRequestURI();
LocalDateTime requestTime = LocalDateTime.now();
log.info("Admin Access Log - User ID: {}, Request Time: {}, Request URL: {}, Method: {}",
userId, requestTime, requestUrl, joinPoint.getSignature().getName());
}
}
@Before 대신 @AfterReturning, @AfterThrowing 같은 어노테이션은 언제 사용하는 것이 좋을까?
- @Before는 메서드가 호출되기 직전에 실행되며, 메서드의 결과에 상관없이 항상 실된다. 반면, @AfterReturning과 @AfterThrowing은 메서드의 결과에 따라 동작한다. 두 어노테이션은 메서드 실행 후 결과에 따라 특정 로직을 수행할 때 사용된다.
- @AfterReturning
- 메서드가 정상적으로 실행되어 반환값을 돌려줄 때 사용함
- 메서드의 반환 값을 기록하거나, 반환값을 조작할때
- 메서드가 정상적으로 완료된 후의 특정 후처리 로직을 수행할
@AfterReturning(pointcut = "execution(* org.example.service.UserService.findUser(..))", returning = "result")
public void logAfterReturningFindUser(JoinPoint joinPoint, Object result) {
log.info("Method {} returned value {}", joinPoint.getSignature().getName(), result);
}
- @AfterThrowing
- 메서드 실행 중 예외가 발생했을 때 사용함
- 예외 발생 시 로그를 남기거나 예외 처리 로직을 수행할 때
- 특정 예외가 발생한 후 복구 로직을 수행하거나 알림을 보내는 경우
@AfterThrowing(pointcut = "execution(* org.example.service.UserService.findUser(..))", throwing = "exception")
public void logAfterThrowingFindUser(JoinPoint joinPoint, Throwable exception) {
log.error("Method {} threw exception {}", joinPoint.getSignature().getName(), exception.getMessage());
}
AOP를 사용할 때 성능 문제를 방지하기 위한 방법은?
AOP는 강력한 기능을 제공하지만, 잘못 사용하면 성능 문제를 초래할 수 있다. AOP를 효율적으로 사용하여 성능 문제를 방지하기 위한 방법은 아래와 같다.
- 포인트컷 최적화
- 구체적인 포인트컷을 사용하여 가능한 좁은 범위에 AOP를 적용하도록 구체적인 클래스와 메서드를 타겟팅한다. execution(* com.example..*(..))처럼 너무 광범위한 포인트컷은 불필요하게 많은 메서드에 적용될 수 있으므로, 성능 저하를 유발할 수 있다.
- Advice 최소화
- 모든 메서드에 AOP를 적용하기보다는 특정 조건에 맞을 때만 실행하도록 설정한다.
- 예를들어, 메서드의 반환값이 특정 조건을 만족하거나, 예외가 발생했을 때만 로직이 실행되도록 @AfterReturning이나 @AfterThrowing을 사용한다.
- 빈 생성 시점 최적화
- AOP는 프록시 기반으로 동작하므로, 프록시 생성 시점에서 추가적인 비용이 발생할 수 있다. 프록시를 생성하는 시점과 메모리 관리에 신경써야한다.
- Lazy Initialization 사용
- AOP 빈을 필요할 때만 생성하고 실행되도록 @Lazy 를 사용하여 초기화 비용을 줄일 수 있다.
개인과제 Level 1-4 컨트롤러 테스트의 이해
문제
테스트 패키지 org.example.expert.domain.todo.controller의 todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다() 테스트가 실패하고 있어요.
테스트가 정상적으로 수행되어 통과할 수 있도록 코드를 수정해주세요.
@Test
void todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다() throws Exception {
// given
long todoId = 1L;
// when
when(todoService.getTodo(todoId))
.thenThrow(new InvalidRequestException("Todo not found"));
// then
mockMvc.perform(get("/todos/{todoId}", todoId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value(HttpStatus.OK.name()))
.andExpect(jsonPath("$.code").value(HttpStatus.OK.value()))
.andExpect(jsonPath("$.message").value("Todo not found"));
}
풀이
- 테스트코드의 오류를 확인했을 때, 테스트에서 기대한 상태 코드가 200 OK인데 실제로 반환된 상태 코드는 400 Bad Request이다.
- 예외 발생시 400 Bad Request가 적절한 상태코드이기 때문에, 테스트 코드에서 400 Bad Request를 반환하도록 수정했다.
@Test
void todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다() throws Exception {
// given
long todoId = 1L;
// when
when(todoService.getTodo(todoId))
.thenThrow(new InvalidRequestException("Todo not found"));
// then
mockMvc.perform(get("/todos/{todoId}", todoId))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("Todo not found"));
}
개인과제 Level 1-5 JPA의 이해
문제
기획자의 긴급 요청이 왔어요! 아래의 요구사항에 맞춰 기획 요건에 대응할 수 있는 코드를 작성해주세요.
- 할 일 검색 시 `weather` 조건으로도 검색할 수 있어야해요.
- `weather` 조건은 있을 수도 있고, 없을 수도 있어요!
- 할 일 검색 시 수정일 기준으로 기간 검색이 가능해야해요.
- 기간의 시작과 끝 조건은 있을 수도 있고, 없을 수도 있어요!
- JPQL을 사용하고, 쿼리 메소드명은 자유롭게 지정하되 너무 길지 않게 해주세요.
💡 필요할 시, 서비스 단에서 if문을 사용해 여러 개의 쿼리 메소드를 사용하셔도 좋습니다.
풀이
- 사용자가 입력한 날씨 조건과 기간 조건(수정일)을 이용해 데이터를 필터링하며, 이 조건들은 있을 수도 있고 없을 수도 있는 선택적 조건이다. 이 요구사항을 해결하기 위해 JPA의 JPQL과 동적 쿼리 처리를 사용했다.
- TodoRepository에서는 JPQL을 사용하여 조건에 맞는 데이터를 검색한다. 날씨와 기간 조건이 없을 때도 유연하게 동작하도록 동적 쿼리를 작성했다.
public interface TodoRepository extends JpaRepository<Todo, Long> {
@Query("SELECT t FROM Todo t LEFT JOIN FETCH t.user u " +
"WHERE (:weather IS NULL OR t.weather = :weather) " +
"AND (:startDate IS NULL OR t.modifiedAt >= :startDate) " +
"AND (:endDate IS NULL OR t.modifiedAt <= :endDate) " +
"ORDER BY t.modifiedAt DESC")
Page<Todo> findByWeatherAndModifiedAtBetween(
@Param("weather") String weather,
@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate,
Pageable pageable);
}
- 날씨 조건과 기간 조건을 클라이언트로부터 받을 수 있도록 컨트롤러를 수정하고, 사용자로부터 받은 LocalDate를 JPA에서 사용하기 위해 LocalDateTime으로 변환한다. 이는 검색 기간의 자정부터 하루의 끝까지를 정확히 표현하기 위해서이다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/todos")
public class TodoController {
private final TodoService todoService;
@GetMapping
public ResponseEntity<Page<TodoResponse>> getTodos(
@RequestParam(required = false) String weather,
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate,
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size
) {
Page<TodoResponse> todos = todoService.getTodos(weather, startDate, endDate, page, size);
return ResponseEntity.ok(todos);
}
}
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TodoService {
private final TodoRepository todoRepository;
public Page<TodoResponse> getTodos(String weather, LocalDate startDate, LocalDate endDate, int page, int size) {
Pageable pageable = PageRequest.of(page - 1, size);
LocalDateTime startDateTime = startDate != null ? startDate.atStartOfDay() : null;
LocalDateTime endDateTime = endDate != null ? endDate.atTime(LocalTime.MAX) : null;
Page<Todo> todos = todoRepository.findByWeatherAndModifiedAtBetween(weather, startDateTime, endDateTime, pageable);
return todos.map(todo -> new TodoResponse(
todo.getId(),
todo.getTitle(),
todo.getContents(),
todo.getWeather(),
new UserResponse(todo.getUser().getId(), todo.getUser().getEmail()),
todo.getCreatedAt(),
todo.getModifiedAt()
));
}
}
JPQL (Java Persistence Query Language)
JPQL은 JPA에서 제공하는 객체 지향 쿼리 언어로, SQL과 비슷하지만 데이터베이스 테이블이 아닌 엔티티 객체를 대상으로 쿼리를 작성한다. 때문에 JPQL은 데이터베이스 테이블이 아닌 JPA 엔티티를 기반으로 동작한다.
- 특징
- JPQL은 엔티티 객체를 대상으로 쿼리를 작성하므로 객체 지향적인 방식으로 데이터 검색이 가능하다.
- JPQL은 엔티티의 필드를 데이터베이스 컬럼과 매핑하여 자동으로 쿼리를 처리한다.
- JPQL은 파라미터를 통해 동적 쿼리를 작성할 수 있으며, 복잡한 검색 조건도 쉽게 처리할 수 있다.
- JPQL을 사용하는 이유
- JPA의 객체 지향적인 접근 방식을 유지하면서 데이터베이스에 대한 검색을 효율적으로 처리하기 위해서 사용된다. SQL로도 데이터를 조회할 수 있지만, 엔티티 객체를 직접 사용하여 개발자의 코드 가독성을 높이고, 유지보수를 쉽게 할 수 있다.
- 예시
- SELECT: 엔티티 객체를 조회.
- FROM: 조회할 엔티티를 지정.
- WHERE: 조건을 추가.
- JOIN: 엔티티 간의 관계를 통해 조인 쿼리를 작성.
- ORDER BY: 정렬 조건을 지정.
// 모든 User 엔티티 조회
SELECT u FROM User u
// 특정 필드를 조건으로 조회
SELECT t FROM Todo t WHERE t.weather = 'Sunny'
// JOIN을 이용한 복잡한 조회
SELECT t FROM Todo t JOIN t.user u WHERE u.email = :email
📝 회고
쿼리가 왜이렇게 어려운지 모르겠다. SQL 문제를 꽤 풀었다 생각했는데 부족했나보다.
이번 과제는 기존 과제들과 많이 다르지 않아 수행하는데 어려움을 크게 느끼진 않지만
여러가지를 동시 수정해야 될때 순서나 체크해야될 것들이 완벽하게 숙지되지 않아
여러번 테스트를 돌리면서 확인하는 과정을 거쳐야했다.
이제 곧 팀프로젝트와 최종 프로젝트가 시작되는데,
이번 개인과제기간 동안 부족한 것들을 완벽하게 잡고 가야겠다.
🔖 Tomorrow's Goal
- 필수과제 끝내기 !
- 기술블로그 방향 잡기!