ํ์ : ๊ฐ์ธ ํ๋ก์ ํธ
๊ธฐ๊ฐ : 2025.01 ~ ์งํ ์ค
๋งํฌ : https://github.com/M-ung/TicToc_Server
์๋น์ค ๋ด์ฉ : ๋น์ ์ ์๊ฐ์ ๊ฐ์น๋ฅผ ๋งค๊ธฐ๋ค, ์๊ฐ ๊ฑฐ๋ ๊ฒฝ๋งค ํ๋ซํผ
"TicToc" ์๋น์ค๋ฅผ ๊ฐ๋ฐํ๋ฉด์ ์ค์ ๋ฐฐํฌ๋ฅผ ๋ชฉ์ ์ผ๋ก ํ๊ธฐ ๋๋ฌธ์ ์๋น์ค๋ฅผ ์ด์ํ๋ ์ ์ฅ์์ ์ค๊ณ๋ฅผ ํ๊ฒ ๋๋ค. ๊ทธ๋์ ์ฌ์ฉ์ ๋ก๊ทธ์ธ ๊ธฐ๋ก์ ๊ฐ๊ณ ์๋๊ฒ ์ข๋ค๊ณ ํ๋จํ์๊ธฐ์ ์ฌ์ฉ์ ๋ก๊ทธ์ธ ๊ธฐ๋ก(UserLoginHistory) ํ ์ด๋ธ์ ๋ฐ๋ก ์์ฑํ๋ค.
๋ก๊ทธ์ธ ๊ธฐ๋ก์ ์ฌ์ฉ์๊ฐ ๋ก๊ทธ์ธ ํ ์์ ์ ์ฆ JWT ํ ํฐ ๋ฐ๊ธ ์์ ์ ์ ์ฅ์ ํ๋๋ก ๊ธฐ๋ฅ์ ๊ตฌํํ๋ค.
ํ์ง๋ง ์ฌ๊ธฐ์ ๋ฌธ์ ๋ ์ฌ์ฉ์ ๋ก๊ทธ์ธ ๊ธฐ๋ก์ ๋จ์ํ DB์ ์ ์ฅํ๊ธฐ์๋ ํ ์ฌ์ฉ์๊ฐ ํ๋ฃจ์ ๋ก๊ทธ์ธ์ 10๋ฒ๋ง ํด๋ 10๊ฐ๊ฐ ์์ด๋ ์ํฉ์ด ๋ฐ์ํ๋ค. ๊ทธ๋ ์ง๋ง ํ๋ฃจ์ ํ ๋ช ๋ง ์ ์ํ ์ผ์ด ์๋ค. 10๋ช ์ ์ฌ์ฉ์๊ฐ 10๋ฒ ๋ก๊ทธ์ธ์ ํ๋ฉด ํ ์ด๋ธ์๋ ํ๋ฃจ์๋ง 100๊ฐ๊ฐ ๋๊ฒ ์์ด๊ฒ ๋๋ค.
๊ทธ๋์ ์ ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ๊ธฐ ์ํด ๋จ์ํ DB์ ๊ณ์ ์ ์ฅํ๊ธฐ ๋ณด๋จ ์๊ฐ์ ๋๊ณ ํน์ ๊ธฐ๊ฐ์ ๋ฐ์ดํฐ๋ฅผ ์ง์ธ ํ์๊ฐ ์๋ค๊ณ ํ๋จํ๋ค. ๊ทธ๋์ Spring Batch๋ฅผ ์ ์ฉํด ์ผ์ฃผ์ผ๋ง๋ค ์ผ์ฃผ์ผ ์น ๋ฐ์ดํฐ๋ฅผ ์ง์ฐ๋ ์ค๊ณ๋ฅผ ํ๊ธฐ๋ก ํ๋ค.
ํ์ง๋ง ๋จ์ํ ๋ ๋ฐ์ดํฐ๋ฅผ ์๊ฐ์ ๋๊ณ ์ง์ฐ๊ธฐ์๋ ์๋น์ค๋ฅผ ์ด์ํ๋ ์ ์ฅ์์ ์ข์ง ์์ ๋ฐฉํฅ์ผ ์ ์๋ค. ๊ทธ๋ ๊ธฐ ๋๋ฌธ์ ๋ชจ๋ ๋ก๊ทธ์ธ ๊ธฐ๋ก์ ์ ์ฅํ ๊ณต๊ฐ์ด ํ์ํ๋ค. ๊ทธ๋์ DB๊ฐ ์๋, UserLoginHistory.log๋ผ๋ ํ์ผ์ ๋ง๋ค์ด ๋ก๊ทธ์ธ ๊ธฐ๋ก์ DB์ log ํ์ผ์ ๋์์ ์ ์ฅํ๊ธฐ๋ก ํ๋ค.
Spring Batch ์ฝ๋๋ ์๋์ ๊ฐ๋ค.
๐ UserLoginHistoryBatchScheduler.java
@Component
@RequiredArgsConstructor
public class UserLoginHistoryBatchScheduler {
private final JobLauncher jobLauncher;
private final Job userLoginHistoryJob;
@Scheduled(cron = "0 0 0 * * SUN")
public void runBatchJob() throws JobExecutionException {
JobParameters jobParameters = new JobParametersBuilder()
.addLong("time", System.currentTimeMillis())
.toJobParameters();
jobLauncher.run(userLoginHistoryJob, jobParameters);
}
}
๐ UserLoginHistoryBatchConfig.java
@Configuration
@RequiredArgsConstructor
public class UserLoginHistoryBatchConfig {
private final JobRepository jobRepository;
private final DataSource dataSource;
private final PlatformTransactionManager transactionManager;
@Bean
public Job userLoginHistoryJob(Step step) {
return new JobBuilder("userLoginHistoryJob", jobRepository)
.start(step)
.build();
}
@Bean
public Step userLoginHistoryStep() {
return new StepBuilder("userLoginHistoryStep", jobRepository)
.<Long, Long>chunk(100, transactionManager)
.reader(userLoginHistoryReader())
.writer(userLoginHistoryItemWriter())
.build();
}
@Bean
public JdbcCursorItemReader<Long> userLoginHistoryReader() {
LocalDateTime endDate = LocalDateTime.now();
LocalDateTime startDate = endDate.minusDays(7);
return new JdbcCursorItemReaderBuilder<Long>()
.dataSource(dataSource)
.name("userLoginHistoryReader")
.sql("SELECT id FROM user_login_history WHERE login_at BETWEEN ? AND ?")
.queryArguments(Timestamp.valueOf(startDate), Timestamp.valueOf(endDate))
.rowMapper((rs, rowNum) -> rs.getLong("id"))
.build();
}
@Bean
public ItemWriter<Long> userLoginHistoryItemWriter() {
return items -> {
NamedParameterJdbcTemplate namedParameterJdbcTemplate = new NamedParameterJdbcTemplate(dataSource);
if (items.isEmpty()) return;
String sql = "DELETE FROM user_login_history WHERE id IN (:ids)";
Map<String, Object> params = new HashMap<>();
params.put("ids", items);
namedParameterJdbcTemplate.update(sql, params);
};
}
}
Spring Batch ์๋๋ฆฌ์ค๋ ์๋์ ๊ฐ๋ค.
ํ์ง๋ง Spring Batch๋ฅผ ๋์ํ ๋ ๋ง์ฝ Spring ์๋ฒ๊ฐ ๋ค์ด๋๋ฉด Spring Batch ๋ํ ์ ๋ ๋์์ ์ ๋๋ก ์ํํ์ง ๋ชป ํ ์ ์๋ค. ๊ทธ๋ ๊ธฐ ๋๋ฌธ์ Spring Batch๋ฅผ ๋์ํ ์๋ฒ์ API ์๋ฒ๋ฅผ ๋ถ๋ฆฌํด์ ๋ฐฐํฌํด์ผ๊ฒ ๋ค๋ ์๊ฐ์ ํ๋ค.
๋คํํ ์ฐ๋ฆฌ ํ๋ก์ ํธ๋ ๋ฉํฐ๋ชจ๋๋ก ๊ตฌ์ฑ๋์ด ์๊ธฐ ๋๋ฌธ์ ๋ถ๋ฆฌํ๊ธฐ ์ด๋ ต์ง ์์๋ค.
ํ์ง๋ง ์ฌ๊ธฐ์ ๋ฌธ์ ๋ ์ฌ์ฉ์ ๋ก๊ทธ์ธ ๊ธฐ๋ก์ DB์ log ํ์ผ์ ์ ์ฅํ๋ ๋ก์ง์ Spring Batch ์๋ฒ์์ ๊ด๋ฆฌํ๋ ค๊ณ ํ์ง๋ง, API ์๋ฒ์ Spring Batch ์๋ฒ๋ฅผ ์ด์ด์ค "๋ฌด์ธ๊ฐ" ๊ฐ ํ์ํ๋ค.
์ฒ์์๋ WebFlux๋ฅผ ์ ์ฉํด ๊ด๋ฆฌํ๋ ค ํ์ง๋ง, ์ฌ์ฉ์ ๋ก๊ทธ์ธ ๊ธฐ๋ก์ ์์๊ฐ์ ๋ง์ ์์ฒญ์ด ๋ค์ด์ฌ ์ ์๊ณ ๋์์์ด ์์ฒญ์ด ๋ค์ด์ฌ ์ ์๊ธฐ ๋๋ฌธ์ ๋์ฉ๋์ ๊ด๋ฆฌํ ํ์๊ฐ ์์๋ค.
๊ทธ๋์ "์นดํ์นด" ๋ฅผ ์ ์ฉํด ๋ณด๊ธฐ๋ก ํ๋ค.
์นดํ์นด ์ฝ๋๋ ์๋์ ๊ฐ๋ค.
๐ UserLoginHistoryEventProducer.java
@Slf4j
@Component
@RequiredArgsConstructor
public class UserLoginHistoryEventProducer {
private final HttpServletRequest request;
private final KafkaTemplate<String, UserLoginHistoryEvent> kafkaTemplate;
@Async
public void produce(Long userId) {
try {
UserLoginHistoryEvent event = UserLoginHistoryEvent.of(userId, getClientIp(), getUserAgent());
log.info("[INFO] ํ ํฝ์ ๋ฐํํ์ต๋๋ค.: {}", event);
kafkaTemplate.send("user-login-history-topic", event);
} catch (Exception e) {
throw new KafkaPublishException(ErrorCode.KAFKA_PUBLISH_ERROR);
}
}
private String getClientIp() {
var ip = request.getHeader(UserLoginHistoryConstants.X_FORWARDED_FOR);
if (ip == null || ip.isEmpty() ||UserLoginHistoryConstants.UNKNOWN.equalsIgnoreCase(ip))
ip = request.getRemoteAddr();
return ip;
}
private String getUserAgent() {
return request.getHeader(UserLoginHistoryConstants.USER_AGENT);
}
}
๐ UserLoginHistoryEventConsumer.java
@Slf4j
@Component
@RequiredArgsConstructor
public class UserLoginHistoryEventConsumer {
private final UserLoginHistoryRepository userLoginHistoryRepository;
private final KafkaTemplate<String, UserLoginHistoryEvent> kafkaTemplate;
private static final String LOG_FILE_PATH = "/var/log/tictoc/user_login_history.log";
private static final String LOG_MESSAGE_FORMAT = "%s - Id: %d, UserId: %d, IPAddress: %s, Device: %s%n";
@KafkaListener(
topics = "user-login-history-topic",
groupId = "${spring.kafka.consumer.group-id}",
containerFactory = "kafkaListenerContainerFactory"
)
public void consume(UserLoginHistoryEvent event) {
try {
log.info("[INFO] ํ ํฝ์ ์๋นํ์ต๋๋ค.: {}", event);
saveUserLoginHistory(UserLoginHistory.of(event.userId(), event.loginAt(), event.ipAddress(), event.device()));
} catch (Exception e) {
kafkaTemplate.send("user-login-history-topic.DLT", event);
throw new KafkaConsumeException(KAFKA_CONSUME_ERROR, e);
}
}
public void saveUserLoginHistory(UserLoginHistory loginHistory) {
userLoginHistoryRepository.save(loginHistory);
writeLogToFile(loginHistory);
}
private void writeLogToFile(UserLoginHistory loginHistory) {
File logFile = ensureLogFileExists();
synchronized (this) {
try (BufferedWriter writer = new BufferedWriter(new FileWriter(logFile, true))) {
writer.write(String.format(LOG_MESSAGE_FORMAT,
loginHistory.getLoginAt(), loginHistory.getId(), loginHistory.getUserId(), loginHistory.getIpAddress(), loginHistory.getDevice()));
} catch (IOException e) {
throw new LogFileWriteException(LOG_FILE_WRITE_ERROR, e);
}
}
}
private static File ensureLogFileExists() {
File logFile = new File(LOG_FILE_PATH);
File directory = logFile.getParentFile();
if (!directory.exists() && directory.mkdirs()) {
setFilePermissions(directory, "755");
}
if (!logFile.exists()) {
try {
if (logFile.createNewFile()) {
setFilePermissions(logFile, "666");
}
} catch (IOException e) {
throw new LogFileWriteException(LOG_FILE_CREATION_ERROR, e);
}
}
return logFile;
}
private static void setFilePermissions(File file, String permission) {
try {
Process process = new ProcessBuilder("chmod", permission, file.getAbsolutePath()).start();
if (process.waitFor() == 0) {
log.info("[INFO] chmod {} ๊ถํ ์ค์ ์๋ฃ: {}", permission, file.getAbsolutePath());
} else {
log.error("[ERROR] chmod {} ์คํจ: {}", permission, file.getAbsolutePath());
}
} catch (Exception e) {
log.error("[ERROR] chmod {} ์คํ ์ค ์์ธ ๋ฐ์: {}", permission, file.getAbsolutePath(), e);
}
}
}
์ ๊ตฌํ ๊ฒฐ๊ณผ ์๋๋ฆฌ์ค๋ ์๋์ ๊ฐ๋ค.
1. ์ฌ์ฉ์๊ฐ ๋ก๊ทธ์ธ์ ํ๋ค.
2. API ์๋ฒ๋ ๋ก๊ทธ์ธ ์์ฒญ์ด ๋ค์ด์ค๋ฉด JWT ํ ํฐ์ ๋ฐ๊ธํ๊ณ ๋ฐ๊ธ๊ณผ ๋์์ ์นดํ์นด ์๋ฒ์ ์ฌ์ฉ์ ๋ก๊ทธ์ธ ๊ธฐ๋ก ๋ฉ์ธ์ง๋ฅผ ์ ์กํ๋ค.
3. Kafka๋ ๋ฉ์์ง๋ฅผ ํ์ ์ ์ฅํ์ฌ Spring Batch ์๋ฒ๊ฐ ๋ฉ์์ง๋ฅผ ์ฒ๋ฆฌํ ์ ์๋๋ก ํ๋ค.
4. Spring Batch ์๋ฒ๋ Kafka์ ๋ฉ์์ง๋ฅผ ์ด๋ฒคํธ ๊ธฐ๋ฐ์ผ๋ก ์ค์๊ฐ ์์ ํ๋ค.
5. Spring Batch ์๋ฒ์์ ๋ฐ์ ๋ฉ์์ง๋ฅผ ๊ธฐ๋ฐ์ผ๋ก DB ์ ์ฅ ๋ฐ ๋ก๊ทธ ํ์ผ์ ๊ธฐ๋กํ๋ค.
๋ฌธ์ ํด๊ฒฐ๋ก ์๋์ ๊ฐ์ ๊ฒฐ๊ณผ๋ฅผ ์ป์๋ค.
1. Kafka๋ก ์ด๋ฒคํธ ๋น๋๊ธฐ ์ฒ๋ฆฌ โ ์๋ต ์๋ ํฅ์, DB ๋ถํ ๊ฐ์.
2. Spring Batch ์๋ฒ์์ ์ฃผ๊ธฐ์ DB ์ญ์ ๋ฐ ๋ก๊ทธ ํ์ผ ๋ฐฑ์
โ ๋ฐ์ดํฐ ์ ์ค ๋ฐฉ์ง.
3. API ์๋ฒ์ Batch ์๋ฒ๋ฅผ ๋ถ๋ฆฌํ์ฌ, ํ์ฅ์ฑ๊ณผ ์ ์ง๋ณด์ ์ฉ์ด์ฑ ํ๋ณด.
์ต์ข ์ ์ธ ์ํคํ ์ฒ ํ๋ฆ์ ์๋์ ๊ฐ๋ค.
์ด๋ฒ ๊ฒฝํ์ ํตํด ์ค์ ์ด์ ํ๊ฒฝ์ ๊ณ ๋ คํ ์ฌ์ฉ์ ๋ก๊ทธ์ธ ๊ธฐ๋ก ์ ์ฅ ๋ฐฉ์์ ๊ณ ๋ฏผํ๋ฉฐ ์ฌ๋ฌ ๊ฐ์ง ๋ฌธ์ ๋ฅผ ํด๊ฒฐํด ๋๊ฐ ์ ์์๋ค.
์ฒ์์๋ ๋จ์ํ DB์ ๋ก๊ทธ์ธ ๊ธฐ๋ก์ ์ ์ฅํ๋ ๋ฐฉ์์ ์ฌ์ฉํ๋ ค ํ์ง๋ง, ๋ก๊ทธ์ธ ์์ฒญ์ด ๋ง์์ง์๋ก ๋ฐ์ดํฐ๊ฐ ๊ธฐํ๊ธ์์ ์ผ๋ก ์ฆ๊ฐํ๋ ๋ฌธ์ ๋ฅผ ์ฒ์ ๋ง์ฃผํ๋ค.
์ด๋ฅผ ํด๊ฒฐํ๊ธฐ ์ํด Spring Batch๋ฅผ ํ์ฉํด ์ฃผ๊ธฐ์ ๋ฐ์ดํฐ ์ญ์ ๋ฅผ ์ ์ฉํ๋ ค ํ์ง๋ง, ๋ก๊ทธ์ธ ๊ธฐ๋ก์ ๋ชจ๋ ์ญ์ ํ๋ ๊ฒ์ ์ด์์ ์ธ ์ธก๋ฉด์์ ๋ฌธ์ ๋ ๊ฒ ๊ฐ์๋ค.
๊ทธ๋์ DB๋ฟ๋ง ์๋๋ผ ๋ก๊ทธ ํ์ผ์๋ ์ ์ฅํ๋ ๋ฐฉ์์ ์ฑํํ๋ค.
ํ์ง๋ง API ์๋ฒ์์ ์ง์ Spring Batch ์๋ฒ๋ก ๋ฐ์ดํฐ๋ฅผ ์ ์ฅํ๋ ค๋ฉด ์๋ฒ ๊ฐ ํต์ ๋น์ฉ์ด ์ฆ๊ฐํ๊ณ , ์๋ฒ ์ฅ์ ๋ฐ์ ์ ๋ฐ์ดํฐ ์ ์ค ์ํ์ด ์์๋ค.
์ด๋ฅผ ํด๊ฒฐํ๊ธฐ ์ํด Kafka๋ฅผ ํ์ฉํ์ฌ API ์๋ฒ์ Spring Batch ์๋ฒ๋ฅผ ๋ถ๋ฆฌํ์๋ค.
Kafka๋ฅผ ๋์
ํจ์ผ๋ก์จ ๋ก๊ทธ์ธ ๊ธฐ๋ก์ ๋น๋๊ธฐ์ ์ผ๋ก ์ฒ๋ฆฌํ ์ ์์์ผ๋ฉฐ, ๋ฐ์ดํฐ ์ ์ค์ ๋ฐฉ์งํ ์ ์์๋ค.
๋ Spring Batch ์๋ฒ๊ฐ API ์๋ฒ์ ๋ณ๋๋ก ์ด์๋๋๋ก ๊ตฌ์ฑํ์ฌ, API ์๋ฒ์ ๋ถํ๋ฅผ ์ค์ด๊ณ ์์ ์ ์ธ ๋ฐ์ดํฐ ์ฒ๋ฆฌ๋ฅผ ๋ณด์ฅํ ์ ์๋๋ก ํ๋ค.
์ด๋ฒ ๊ฒฝํ์์ ๋จ์ํ ๊ธฐ๋ฅ ๊ตฌํ์ด ์๋๋ผ, ์ค์ ์๋น์ค ์ด์์ ๊ณ ๋ คํ ์ํคํ ์ฒ ์ค๊ณ์ ์ค์์ฑ์ ๋ค์ ํ ๋ฒ ๋๋ ์ ์์๋ค.
์์ผ๋ก๋ ์ด์ ์ธก๋ฉด์์ ์ ์ง๋ณด์ํ๊ธฐ ์ข์ ์ค๊ณ๋ฅผ ๊ณ ๋ฏผํ๋ฉฐ ๊ฐ๋ฐํ๋ ๊ฐ๋ฐ์๋ก ์ฑ์ฅํ ๊ฒ์ด๋ค.