Today's Codekata
# Group Sold Products By The Date
SELECT sell_date,
COUNT(DISTINCT product) AS num_sold,
GROUP_CONCAT(DISTINCT product ORDER BY product SEPARATOR ',') AS products
FROM Activities
GROUP BY sell_date;
# List the Products Ordered in a Period
SELECT p.product_name, SUM(o.unit) AS unit
FROM Products p
JOIN Orders o ON p.product_id = o.product_id
WHERE order_date BETWEEN '2020-02-01' AND '2020-02-29'
GROUP BY p.product_name
HAVING unit >= 100;
# Find Users With Valid E-Mails
SELECT *
FROM Users
WHERE mail REGEXP '^[a-zA-Z][a-zA-Z0-9_.-]*@leetcode\\.com$'
AND BINARY mail LIKE '%@leetcode.com';
`GROUP_CONCAT`은 여러 행의 문자열 값을 하나의 문자열로 합쳐주는 집계 함수이다.
`BINARY`는 문자열 비교 시 대소문자를 구분하도록 강제하는 키워드이다.
Today I Learned
Spring 플러스 주차 개인 과제
#담당자 등록/삭제 로그 기능 구현
이번에 구현한 기능은 일정(Todo)의 담당자 등록/삭제 시 로그(Log)를 남기는 기능이다.
단순히 성공한 작업만 기록하는 것이 아니라, 실패한 시도도 로그로 남겨야 했기 때문에 트랜잭션 처리와 예외 핸들링에 대한 고민이 필요했다.
목표
담당자 등록/삭제 시 성공 및 실패 로그 남기기
로그에는 일정 ID, 작성자 ID, 대상 유저 ID, 메시지, 액션 타입(SUCCESS/FAILURE) 포함
실패 상황에서도 로그는 반드시 저장되도록 트랜잭션 분리
테스트 코드로 기능 검증
구현
1. 로그 엔티티 설계
로그는 단순 텍스트 기록이 아니라, 어떤 일정에서 어떤 유저가 어떤 작업을 했는지를 명확히 남겨야 했다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Log extends Timestamped {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long todoId;
@Column(nullable = false)
private Long authorId;
@Column(nullable = false)
private Long managerUserId;
@Enumerated(EnumType.STRING)
private ActionType type;
private String message;
public Log(Long todoId, Long authorId, Long managerUserId, ActionType type, String message) {
this.todoId = todoId;
this.authorId = authorId;
this.managerUserId = managerUserId;
this.type = type;
this.message = message;
}
}
`ActionType`은 enum으로 정의해 성공과 실패를 명확히 구분할 수 있도록 했다.
2. 트랜잭션 분리
담당자 등록/삭제는 예외가 발생할 수 있는 작업이기 때문에, 로그 저장을 같은 트랜잭션에 묶으면 실패 시 로그도 롤백된다.이를 방지하기 위해 로그 저장은 별도의 트랜잭션으로 분리했다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog(Log log) {
logRepository.save(log);
}
`propagation = Propagation.REQUIRES_NEW`를 붙여주면 기존 트랜잭션과는 별도로 새로운 트랜잭션을 시작하게 된다.
이렇게 하면 담당자 등록 또는 삭제가 실패해도 로그는 안전하게 커밋된다.
3. 예외 핸들링과 로그 메시지
예외 발생 시에도 로그를 남기기 위해 `try-catch`로 전체 로직을 감싸고, 실패 메시지를 명확하게 기록해줬다.
try {
// 담당자 등록,삭제 로직
Log log = new Log(
todoId,
user.getId(),
managerUser.getId(),
ActionType.SUCCESS,
String.format("%s님이 %s님을 담당자로 등록했습니다.", user.getNickname(), managerUser.getNickname())
);
logService.saveLog(log);
return ...;
} catch (InvalidRequestException e) {
Log log = new Log(
todoId,
authUser.getId(),
managerSaveRequest.getManagerUserId(),
ActionType.FAILURE,
"담당자 등록 실패: " + e.getMessage()
);
logService.saveLog(log);
throw e;
}
실패 메시지는 예외 메시지를 그대로 활용해 어떤 이유로 실패했는지 추적 가능하도록 했다.
처음엔 `catch` 블록에서 `return`을 어떻게 해야할지 고민했는데, `throw e`를 활용해서 다시 던져주는 방식으로 해결했다.
테스트 코드
성공/실패 로그가 모두 잘 저장되는지 확인하기 위해 테스트 코드를 작성했다.예외 상황에서도 로그가 DB에 남는지 검증하는 것이 핵심이었다.
@SpringBootTest
@Transactional
@ActiveProfiles("test")
public class LogServiceTest {
@Autowired
private LogService logService;
@Autowired
private LogRepository logRepository;
@Test
void 로그_성공_저장() {
// given
Log log = new Log(
1L,
100L,
200L,
ActionType.SUCCESS,
"담당자 등록 성공"
);
// when
logService.saveLog(log);
// then
List<Log> logs = logRepository.findAll();
assertThat(logs).hasSize(1);
assertThat(logs.get(0).getType()).isEqualTo(ActionType.SUCCESS);
assertThat(logs.get(0).getMessage()).contains("성공");
}
@Test
void 로그_실패_저장() {
// given
Log log = new Log(
2L,
101L,
101L,
ActionType.FAILURE,
"담당자 등록 실패: 일정 작성자는 본인을 지정할 수 없습니다."
);
// when
logService.saveLog(log);
// then
Log savedLog = logRepository.findById(log.getId()).orElseThrow();
assertThat(savedLog.getType()).isEqualTo(ActionType.FAILURE);
assertThat(savedLog.getMessage()).contains("실패");
}
}
#마치며
트랜잭션 분리와 예외 핸들링, 로그 설계까지 한 번에 경험해볼 수 있어서 좋았다.
앞으로도 중요한 작업에 대한 로그를 남겨 문제를 바로 추적하고 대응할 수 있는 시스템을 만들면 좋을 듯하다.