diff --git a/build.gradle b/build.gradle index 7a4b184..eee1ee8 100644 --- a/build.gradle +++ b/build.gradle @@ -51,6 +51,9 @@ dependencies { // Swagger (SpringDoc OpenAPI) implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6' + // Logging (JSON 파일 로그) + implementation 'net.logstash.logback:logstash-logback-encoder:8.0' + // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' diff --git a/src/main/java/com/linclean/global/log/DiscordWebhookAppender.java b/src/main/java/com/linclean/global/log/DiscordWebhookAppender.java new file mode 100644 index 0000000..dcfd82b --- /dev/null +++ b/src/main/java/com/linclean/global/log/DiscordWebhookAppender.java @@ -0,0 +1,123 @@ +package com.linclean.global.log; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.classic.spi.ThrowableProxyUtil; +import ch.qos.logback.core.AppenderBase; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +/** + * ERROR 로그를 디스코드 웹훅으로 실시간 전송하는 logback appender. + * + *

logback-spring.xml 에서 {@code ${DISCORD_WEBHOOK_URL:-}} 로 주입한다. + * URL이 비어 있으면 아무 동작도 하지 않으므로(로컬/미설정 환경) 안전하다. + * Spring 컨텍스트보다 먼저 동작하므로 빈 주입 대신 표준 {@link HttpClient}를 사용한다. + * + *

전송은 {@link HttpClient#sendAsync} 로 비동기 처리하며, 디스코드 분당 30회 한도를 + * 넘기지 않도록 분당 {@code maxPerMinute}건으로 제한(초과분은 드롭)한다. + */ +public class DiscordWebhookAppender extends AppenderBase { + + /** 디스코드 content 최대 길이 2000자. 코드블록/여유분 고려해 보수적으로 자른다. */ + private static final int MAX_CONTENT = 1900; + private static final DateTimeFormatter TS_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault()); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private String webhookUrl; + private int maxPerMinute = 20; + + private HttpClient httpClient; + + // 분당 호출 제한 상태 + private long windowStartMs = 0; + private int sentInWindow = 0; + + public void setWebhookUrl(String webhookUrl) { + this.webhookUrl = webhookUrl; + } + + public void setMaxPerMinute(int maxPerMinute) { + this.maxPerMinute = maxPerMinute; + } + + @Override + public void start() { + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(3)) + .build(); + super.start(); + } + + @Override + protected void append(ILoggingEvent event) { + if (webhookUrl == null || webhookUrl.isBlank()) { + return; // 미설정 환경에서는 no-op + } + if (!allow()) { + return; // 분당 제한 초과 → 드롭 + } + try { + String payload = buildPayload(event); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(webhookUrl)) + .timeout(Duration.ofSeconds(5)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(payload)) + .build(); + // 비동기 전송 - 실패해도 애플리케이션 로깅 흐름에 영향 주지 않음 + httpClient.sendAsync(request, HttpResponse.BodyHandlers.discarding()) + .exceptionally(ex -> null); + } catch (Exception e) { + // appender 는 절대 예외를 던지지 않는다 + addError("디스코드 웹훅 전송 실패", e); + } + } + + private synchronized boolean allow() { + long now = System.currentTimeMillis(); + if (now - windowStartMs >= 60_000) { + windowStartMs = now; + sentInWindow = 0; + } + if (sentInWindow < maxPerMinute) { + sentInWindow++; + return true; + } + return false; + } + + private String buildPayload(ILoggingEvent event) { + StringBuilder sb = new StringBuilder(); + sb.append("🚨 **[ERROR]** ").append(TS_FORMAT.format(Instant.ofEpochMilli(event.getTimeStamp()))) + .append('\n') + .append("**logger**: ").append(event.getLoggerName()).append('\n') + .append("**message**: ").append(event.getFormattedMessage()); + + IThrowableProxy throwableProxy = event.getThrowableProxy(); + if (throwableProxy != null) { + sb.append("\n```\n") + .append(ThrowableProxyUtil.asString(throwableProxy)) + .append("\n```"); + } + + String content = sb.toString(); + if (content.length() > MAX_CONTENT) { + content = content.substring(0, MAX_CONTENT) + "\n...(생략)"; + } + + ObjectNode node = OBJECT_MAPPER.createObjectNode(); + node.put("content", content); + return node.toString(); + } +} diff --git a/src/main/java/com/linclean/global/log/LogViewController.java b/src/main/java/com/linclean/global/log/LogViewController.java new file mode 100644 index 0000000..3a31134 --- /dev/null +++ b/src/main/java/com/linclean/global/log/LogViewController.java @@ -0,0 +1,118 @@ +package com.linclean.global.log; + +import com.linclean.global.web.ApiResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * 서버 로그 파일 조회용 내부 엔드포인트. + * + *

{@code /internal/**} 경로이므로 {@code InternalApiKeyFilter}가 자동 적용된다. + * 호출 시 {@code X-Internal-Api-Key} 헤더에 {@code internal.api-key} 값이 필요하다. + * SSH 없이 IntelliJ HTTP Client / 브라우저에서 로그를 확인하기 위한 용도. + */ +@RestController +@RequestMapping("/internal/logs") +public class LogViewController { + + private static final Pattern DATE_PATTERN = Pattern.compile("\\d{4}-\\d{2}-\\d{2}"); + private static final String ACTIVE_FILE = "app.json"; + + @Value("${LOG_DIR:logs}") + private String logDir; + + /** 사용 가능한 로그 파일 목록(파일명/크기). */ + @GetMapping + public ResponseEntity>> listLogFiles() throws IOException { + Path dir = Paths.get(logDir); + if (!Files.isDirectory(dir)) { + return ResponseEntity.ok(ApiResponse.of(List.of())); + } + try (Stream files = Files.list(dir)) { + List result = files + .filter(LogViewController::isLogFile) + .sorted(Comparator.comparing((Path p) -> p.getFileName().toString()).reversed()) + .map(LogViewController::toInfo) + .toList(); + return ResponseEntity.ok(ApiResponse.of(result)); + } + } + + /** + * 특정 날짜의 로그 조회. 오늘 날짜는 롤링 전이므로 활성 파일(app.json)을 가리킨다. + * + * @param date yyyy-MM-dd + * @param level (선택) 해당 레벨 라인만 필터 (예: ERROR) + * @param lines (선택) 마지막 N줄만 반환 + */ + @GetMapping("/{date}") + public ResponseEntity getLogByDate( + @PathVariable String date, + @RequestParam(required = false) String level, + @RequestParam(required = false) Integer lines + ) throws IOException { + if (!DATE_PATTERN.matcher(date).matches()) { + return ResponseEntity.badRequest().body("date 형식은 yyyy-MM-dd 여야 합니다."); + } + + String fileName = date.equals(LocalDate.now().toString()) + ? ACTIVE_FILE + : "app." + date + ".json"; + + Path dir = Paths.get(logDir).toAbsolutePath().normalize(); + Path file = dir.resolve(fileName).normalize(); + if (!file.startsWith(dir) || !Files.isRegularFile(file)) { + return ResponseEntity.notFound().build(); + } + + List all = Files.readAllLines(file, StandardCharsets.UTF_8); + Stream stream = all.stream(); + if (level != null && !level.isBlank()) { + String needle = "\"level\":\"" + level.toUpperCase() + "\""; + stream = stream.filter(line -> line.contains(needle)); + } + List filtered = stream.toList(); + if (lines != null && lines > 0 && filtered.size() > lines) { + filtered = filtered.subList(filtered.size() - lines, filtered.size()); + } + + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("application/x-ndjson; charset=UTF-8")) + .body(String.join("\n", filtered)); + } + + private static boolean isLogFile(Path p) { + String name = p.getFileName().toString(); + return name.startsWith("app") && name.endsWith(".json"); + } + + private static LogFileInfo toInfo(Path p) { + long size; + try { + size = Files.size(p); + } catch (IOException e) { + size = -1; + } + return new LogFileInfo(p.getFileName().toString(), size); + } + + public record LogFileInfo(String fileName, long sizeBytes) { + } +} diff --git a/src/main/resources/db/migration/V20260602.000001__update_terms_documents.sql b/src/main/resources/db/migration/V20260602.000001__update_terms_documents.sql new file mode 100644 index 0000000..dd07e71 --- /dev/null +++ b/src/main/resources/db/migration/V20260602.000001__update_terms_documents.sql @@ -0,0 +1,323 @@ +UPDATE terms +SET title = '서비스 이용약관', + content = $terms$ +# LinClean 서비스 이용약관 + +시행일: 2026년 6월 2일 + +## 제1조 (목적) + +본 약관은 403-Forb2dden(이하 "운영팀")이 제공하는 LinClean 서비스(이하 "서비스")의 이용과 관련하여 운영팀과 이용자 사이의 권리, 의무 및 책임사항, 서비스 이용 조건과 절차를 정함을 목적으로 합니다. + +## 제2조 (운영자 정보) + +- 서비스 운영자: 403-Forb2dden +- 대표자: 이선민 +- 사업자등록번호: 해당 없음 +- 주소: 별도 사업장 없음 +- 고객문의: linclean2026@gmail.com + +본 서비스는 캡스톤 프로젝트 결과물로 제공되는 URL 안전성 분석 및 링크 관리 서비스이며, 학교가 직접 운영하거나 서비스에 대한 법적 책임을 부담하는 서비스가 아닙니다. + +## 제3조 (용어의 정의) + +1. "서비스"란 이용자가 URL을 입력하거나 공유하여 안전성 분석 결과를 확인하고, 분석한 링크를 저장 및 관리할 수 있도록 운영팀이 제공하는 LinClean 앱 및 관련 기능을 말합니다. +2. "이용자"란 본 약관에 따라 서비스를 이용하는 회원을 말합니다. +3. "회원"이란 Clerk 등 인증 서비스를 통해 로그인하고 서비스를 이용하는 이용자를 말합니다. +4. "URL 분석"이란 이용자가 입력한 URL, 리다이렉트 결과, 페이지 신호, 위협 데이터베이스 조회 결과 등을 바탕으로 안전, 주의, 위험 등 판단 결과를 제공하는 기능을 말합니다. +5. "저장 링크"란 이용자가 서비스 내에서 저장한 URL, 제목, 설명, 카테고리 등 링크 관리 정보를 말합니다. + +## 제4조 (약관의 게시와 개정) + +1. 운영팀은 본 약관의 내용을 서비스 화면 또는 연결 화면을 통해 이용자가 확인할 수 있도록 게시합니다. +2. 운영팀은 관련 법령을 위반하지 않는 범위에서 본 약관을 개정할 수 있습니다. +3. 약관이 변경되는 경우 운영팀은 변경 내용, 시행일 및 변경 사유를 서비스 내 공지사항 또는 약관 화면을 통해 안내합니다. +4. 이용자가 변경된 약관에 동의하지 않는 경우 서비스 이용을 중단하고 회원 탈퇴를 요청할 수 있습니다. + +## 제5조 (회원가입 및 로그인) + +1. 서비스는 Clerk 및 Google OAuth 등 외부 인증 서비스를 이용하여 로그인 기능을 제공합니다. +2. 운영팀은 인증 서비스로부터 서비스 이용에 필요한 회원 식별 정보를 제공받거나 검증할 수 있습니다. +3. 이용자는 본인의 계정 정보를 안전하게 관리해야 하며, 계정의 부정 사용이 의심되는 경우 즉시 운영팀에 알려야 합니다. +4. 운영팀은 이미 탈퇴 처리된 계정 또는 서비스 운영상 제한이 필요한 계정에 대해 로그인을 제한할 수 있습니다. + +## 제6조 (서비스의 내용) + +운영팀은 이용자에게 다음 각 호의 기능을 제공합니다. + +1. URL 입력 또는 공유를 통한 안전성 분석 +2. 분석 결과, 점수, 요약 및 위험 사유 제공 +3. 분석한 링크 저장, 제목 수정, 북마크, 카테고리 관리 +4. 서비스 공지사항 및 이용 안내 제공 +5. 그 밖에 운영팀이 서비스 개선을 위해 제공하는 기능 + +## 제7조 (URL 분석 결과의 성격과 한계) + +1. 서비스의 URL 분석 결과는 이용자의 판단을 돕기 위한 참고 정보입니다. +2. 운영팀은 Google Safe Browsing, URLhaus, OpenAI 등 외부 서비스와 자체 분석 로직을 활용할 수 있으나, 모든 악성 URL 또는 위험 요소를 완전하게 탐지함을 보장하지 않습니다. +3. 안전으로 표시된 URL이라도 이후 사이트 내용이 변경되거나 새로운 위협이 발생할 수 있으며, 위험 또는 주의로 표시된 URL이라도 실제 위험 여부는 상황에 따라 달라질 수 있습니다. +4. 이용자는 분석 결과만을 근거로 중요한 거래, 결제, 개인정보 입력 등을 진행해서는 안 되며, 최종 판단과 책임은 이용자에게 있습니다. + +## 제8조 (이용자의 의무) + +이용자는 서비스를 이용할 때 다음 행위를 해서는 안 됩니다. + +1. 타인의 계정 또는 인증 정보를 무단으로 사용하는 행위 +2. 악성 URL, 불법 콘텐츠, 피싱 사이트 등을 유포하거나 이를 목적으로 서비스를 이용하는 행위 +3. 서비스의 분석 시스템, 서버, 네트워크에 과도한 부하를 주는 행위 +4. 자동화 도구 등을 이용하여 비정상적으로 대량 요청을 발생시키는 행위 +5. 운영팀 또는 제3자의 지식재산권, 개인정보, 명예, 신용 등 권리를 침해하는 행위 +6. 관련 법령 또는 본 약관을 위반하는 행위 + +## 제9조 (저장 링크 및 이용자 콘텐츠) + +1. 이용자는 자신이 입력하거나 저장한 URL, 제목, 설명, 카테고리 정보에 대해 필요한 권리와 책임을 가집니다. +2. 운영팀은 서비스 제공, 분석 결과 표시, 링크 관리 기능 제공 및 서비스 안정성 확보를 위해 이용자가 입력한 정보를 처리할 수 있습니다. +3. 운영팀은 이용자가 저장한 정보가 법령 또는 본 약관을 위반하거나 서비스 안정성을 해칠 우려가 있다고 판단하는 경우 해당 정보의 이용을 제한할 수 있습니다. + +## 제10조 (서비스의 변경, 중단 및 종료) + +1. 운영팀은 서비스 개선, 시스템 점검, 장애 대응, 외부 서비스 제공자의 정책 변경 등 필요한 경우 서비스의 전부 또는 일부를 변경하거나 일시 중단할 수 있습니다. +2. 본 서비스는 캡스톤 프로젝트 결과물로 운영되므로, 운영팀 사정에 따라 서비스가 축소, 중단 또는 종료될 수 있습니다. +3. 서비스 종료가 예정된 경우 운영팀은 가능한 범위에서 사전에 서비스 내 공지사항 등을 통해 안내합니다. + +## 제11조 (회원 탈퇴 및 이용계약 해지) + +1. 회원은 앱 내 회원 탈퇴 기능을 통해 언제든지 서비스 이용계약 해지를 요청할 수 있습니다. +2. 운영팀은 회원 탈퇴 요청 즉시 해당 계정을 탈퇴 상태로 전환하고 서비스 이용을 제한합니다. +3. 운영팀은 인증 서비스 제공자인 Clerk에 해당 회원의 계정 삭제를 요청합니다. +4. 탈퇴 회원의 계정 정보, 저장한 링크, URL 분석 이력, 카테고리, 알림 및 디바이스 토큰 등 회원에게 연결된 데이터는 탈퇴일로부터 30일간 보관될 수 있으며, 보관 기간 경과 후 정기적으로 영구 삭제됩니다. +5. 관계 법령상 보관이 필요한 정보가 있는 경우 해당 법령에서 정한 기간 동안 보관할 수 있습니다. + +## 제12조 (지식재산권) + +1. 서비스 및 서비스에 포함된 소프트웨어, 화면 구성, 로고, 문구 등에 관한 권리는 운영팀 또는 정당한 권리자에게 귀속됩니다. +2. 이용자는 서비스를 이용함으로써 서비스 자체에 대한 지식재산권을 취득하지 않습니다. +3. 이용자는 운영팀의 사전 동의 없이 서비스를 복제, 배포, 수정, 역설계하거나 상업적으로 이용해서는 안 됩니다. + +## 제13조 (책임의 제한) + +1. 운영팀은 천재지변, 네트워크 장애, 외부 서비스 장애, 이용자의 귀책사유 등 운영팀의 합리적 통제 범위를 벗어난 사유로 서비스를 제공할 수 없는 경우 책임을 부담하지 않습니다. +2. 운영팀은 URL 분석 결과의 정확성, 완전성, 최신성, 특정 목적 적합성을 보장하지 않습니다. +3. 이용자가 서비스 분석 결과를 참고하여 접속한 외부 사이트, 거래, 다운로드, 개인정보 입력 등으로 인해 발생한 손해에 대해 운영팀은 고의 또는 중대한 과실이 없는 한 책임을 부담하지 않습니다. +4. 운영팀은 이용자 상호 간 또는 이용자와 제3자 사이에서 발생한 분쟁에 개입할 의무가 없습니다. + +## 제14조 (개인정보 보호) + +운영팀은 서비스 제공에 필요한 개인정보를 관련 법령과 개인정보 처리방침에 따라 처리합니다. 개인정보 처리에 관한 자세한 내용은 서비스의 개인정보 처리방침에서 확인할 수 있습니다. + +## 제15조 (준거법 및 분쟁 해결) + +1. 본 약관은 대한민국 법령에 따라 해석됩니다. +2. 서비스 이용과 관련하여 분쟁이 발생한 경우 운영팀과 이용자는 성실히 협의하여 해결합니다. +3. 협의로 해결되지 않는 분쟁은 관련 법령에서 정한 절차에 따릅니다. +$terms$, + content_format = 'markdown', + effective_at = '2026-06-02 00:00:00+09', + updated_at = now() +WHERE type = 'terms_of_service'; + +UPDATE terms +SET title = '개인정보 처리방침', + content = $privacy$ +# LinClean 개인정보 처리방침 + +시행일: 2026년 6월 2일 + +403-Forb2dden(이하 "운영팀")은 LinClean 서비스(이하 "서비스") 이용자의 개인정보를 중요하게 생각하며, 개인정보 보호법 등 관련 법령을 준수하기 위해 다음과 같이 개인정보 처리방침을 수립하고 공개합니다. + +## 제1조 (개인정보처리자 정보) + +- 개인정보처리자: 403-Forb2dden +- 대표자: 이선민 +- 사업자등록번호: 해당 없음 +- 주소: 별도 사업장 없음 +- 고객문의: linclean2026@gmail.com +- 개인정보 보호책임자: 이선민 +- 개인정보 문의: sunmin4142@gmail.com + +본 서비스는 캡스톤 프로젝트 결과물로 운영되며, 학교가 직접 개인정보처리자로서 본 서비스를 운영하는 것은 아닙니다. + +## 제2조 (처리하는 개인정보 항목) + +운영팀은 서비스 제공을 위해 다음의 개인정보 또는 이용 정보를 처리할 수 있습니다. + +| 구분 | 처리 항목 | 처리 시점 | +| --- | --- | --- | +| 회원 인증 | Clerk 회원 식별자, 서비스 내부 회원 식별자, 로그인 인증 정보 | 회원가입, 로그인, 인증 확인 시 | +| 소셜 로그인 | Google 계정을 통한 인증 결과, 인증 서비스가 제공하는 계정 식별 정보 | Google OAuth 로그인 시 | +| URL 분석 | 이용자가 입력한 URL, 리다이렉트 후 최종 URL, URL 분석 결과, 위험도 점수, 분석 요약, 분석 단계 및 사유, 분석 요청 및 처리 시각 | URL 분석 요청 시 | +| 링크 관리 | 저장 링크 제목, 설명, 북마크 여부, 카테고리명, 저장 및 수정 시각 | 링크 저장 및 관리 시 | +| 알림 | 푸시 알림 기능 이용 시 디바이스 토큰, 기기 유형, 알림 제목과 내용, 읽음 여부 | 알림 제공 시 | +| 서비스 운영 | 접속 일시, 요청 기록, 오류 기록, 기기 및 네트워크 환경에서 생성되는 기술 정보 | 서비스 이용 및 장애 대응 시 | + +운영팀은 주민등록번호, 여권번호, 운전면허번호 등 고유식별정보와 민감정보를 의도적으로 수집하지 않습니다. 이용자는 URL, 저장 링크 제목 또는 설명에 본인 또는 타인의 민감한 개인정보를 포함하지 않도록 주의해야 합니다. + +## 제3조 (개인정보의 처리 목적) + +운영팀은 다음 목적을 위해 개인정보를 처리합니다. + +1. 회원 로그인, 인증 및 회원 식별 +2. URL 안전성 분석 및 분석 결과 제공 +3. 저장 링크, 북마크, 카테고리 등 링크 관리 기능 제공 +4. 회원 탈퇴, 재가입 제한 기간 관리 및 계정 삭제 처리 +5. 공지사항, 서비스 안내 및 알림 제공 +6. 서비스 장애 대응, 보안, 부정 이용 방지 및 품질 개선 +7. 이용자 문의 응대 및 분쟁 대응 +8. 관련 법령상 의무 이행 + +## 제4조 (개인정보의 보유 및 이용 기간) + +운영팀은 개인정보 처리 목적이 달성되거나 이용자가 회원 탈퇴를 요청한 경우 지체 없이 해당 정보를 파기합니다. 다만 다음의 경우에는 정해진 기간 동안 보관할 수 있습니다. + +| 구분 | 보유 기간 | 사유 | +| --- | --- | --- | +| 활성 회원 정보 | 회원 탈퇴 시까지 | 서비스 제공 및 회원 관리 | +| URL 분석 이력 및 저장 링크 | 회원 탈퇴 시까지 | 분석 결과 제공 및 링크 관리 | +| 탈퇴 회원의 회원 식별 정보 및 연결 데이터 | 탈퇴일로부터 30일 | 부정 이용 방지, 재가입 처리, 분쟁 대응 및 데이터 삭제 처리 | +| 서비스 운영 로그 | 목적 달성 후 지체 없이 삭제. 단, 보안 및 장애 대응에 필요한 범위에서 합리적인 기간 보관 가능 | 서비스 안정성 확보 | +| 법령상 보관이 필요한 정보 | 해당 법령에서 정한 기간 | 법령상 의무 이행 | + +회원 탈퇴 시 계정은 즉시 탈퇴 상태로 전환되며, 서비스 이용이 제한됩니다. 회원의 계정 정보, 저장한 링크, URL 분석 이력, 카테고리, 알림 및 디바이스 토큰 등 회원에게 연결된 데이터는 탈퇴일로부터 30일간 보관될 수 있으며, 보관 기간 경과 후 정기적으로 영구 삭제됩니다. 운영팀은 현재 탈퇴 회원의 URL 분석 데이터를 익명화하여 별도로 보관하지 않습니다. + +## 제5조 (개인정보의 제3자 제공) + +운영팀은 이용자의 개인정보를 제3자에게 판매하거나 임대하지 않습니다. 다만 다음의 경우에는 개인정보를 제공할 수 있습니다. + +1. 이용자가 사전에 동의한 경우 +2. 법령에 근거가 있거나 수사기관, 법원 등 권한 있는 기관의 적법한 요청이 있는 경우 +3. 서비스 제공을 위해 외부 서비스 또는 처리위탁 업체에 필요한 범위의 정보 처리가 발생하는 경우 + +## 제6조 (개인정보 처리위탁 및 외부 서비스 이용) + +운영팀은 서비스 제공을 위해 다음 외부 서비스를 이용합니다. 외부 서비스 제공자는 각자의 인프라와 정책에 따라 국외에서 정보를 처리할 수 있습니다. + +| 제공자 | 이용 목적 | 처리될 수 있는 정보 | +| --- | --- | --- | +| Clerk, Inc. | 회원 인증, 로그인 세션 관리, 계정 관리 및 탈퇴 시 계정 삭제 요청 | 계정 식별 정보, 로그인 인증 정보, 소셜 로그인 연동 정보 | +| Google LLC | Google OAuth 로그인, Google Safe Browsing을 통한 URL 위험 조회, Firebase Cloud Messaging을 통한 푸시 알림 제공 | Google 로그인 인증 정보, 조회 대상 URL, 디바이스 토큰 및 알림 발송에 필요한 정보 | +| OpenAI, L.L.C. | URL 및 페이지 분석 신호를 바탕으로 위험도 판단을 보조하는 AI 분석 | 분석 대상 URL, 리다이렉트 정보, 페이지에서 추출한 분석 신호, 위협 탐지 결과 등 URL 안전성 판단에 필요한 정보 | +| URLhaus / abuse.ch | 악성 URL 데이터베이스 조회 및 위협 정보 참조 | 조회 대상 URL 또는 URL에서 추출한 도메인 및 경로 정보 | + +운영팀은 외부 서비스에 서비스 제공에 필요한 최소한의 정보만 전송하도록 노력합니다. 각 외부 서비스의 개인정보 처리 방식은 해당 제공자의 개인정보 처리방침 및 약관이 적용될 수 있습니다. + +## 제7조 (국외 이전 가능성) + +Clerk, Google, OpenAI 등 외부 서비스 제공자는 미국 등 국외에 위치한 서버 또는 인프라에서 정보를 처리할 수 있습니다. 국외 이전은 서비스 이용 과정에서 네트워크를 통해 이루어지며, 이전되는 정보는 각 서비스 제공 목적 달성에 필요한 범위로 제한됩니다. 이용자는 본 개인정보 처리방침에 동의하지 않는 경우 서비스 이용을 중단하고 회원 탈퇴를 요청할 수 있습니다. + +## 제8조 (개인정보의 파기 절차 및 방법) + +1. 운영팀은 보유 기간이 경과하거나 처리 목적이 달성된 개인정보를 지체 없이 파기합니다. +2. 전자적 파일 형태의 개인정보는 복구 또는 재생이 어렵도록 삭제합니다. +3. 탈퇴 회원 데이터는 내부 보관 기간 30일이 지난 뒤 정기 삭제 작업을 통해 영구 삭제됩니다. +4. 관계 법령에 따라 보관해야 하는 정보는 다른 정보와 분리하여 해당 기간 동안 보관한 후 파기합니다. + +## 제9조 (이용자의 권리와 행사 방법) + +이용자는 언제든지 본인의 개인정보에 대해 열람, 정정, 삭제, 처리정지, 동의 철회를 요청할 수 있습니다. 회원 탈퇴는 앱 내 회원 탈퇴 기능을 통해 직접 요청할 수 있으며, 개인정보 관련 요청은 개인정보 문의 이메일로 접수할 수 있습니다. + +운영팀은 요청을 받은 경우 관련 법령에 따라 지체 없이 조치합니다. 다만 법령상 보관 의무가 있거나 다른 이용자의 권리 보호, 서비스 보안 및 분쟁 대응을 위해 필요한 경우 일부 요청이 제한될 수 있습니다. + +## 제10조 (아동의 개인정보) + +운영팀은 만 14세 미만 아동을 대상으로 서비스를 제공하지 않으며, 만 14세 미만 아동의 개인정보를 의도적으로 수집하지 않습니다. 만 14세 미만 아동의 개인정보가 수집된 사실을 확인한 경우 운영팀은 지체 없이 해당 정보를 삭제하기 위해 필요한 조치를 취합니다. + +## 제11조 (개인정보 자동 수집 장치) + +서비스는 모바일 앱 환경에서 제공되며, 웹사이트 쿠키를 이용한 맞춤형 광고를 운영하지 않습니다. 다만 Clerk 등 인증 서비스는 로그인 세션 유지와 보안을 위해 토큰, 보안 저장소 또는 이와 유사한 기술을 사용할 수 있습니다. + +## 제12조 (개인정보의 안전성 확보 조치) + +운영팀은 개인정보 보호를 위해 다음과 같은 조치를 취합니다. + +1. 인증이 필요한 API에 대한 접근 제어 +2. 외부 인증 서비스를 통한 로그인 및 토큰 기반 인증 +3. 개인정보 접근 권한의 최소화 +4. 서비스 통신 구간의 암호화 적용 +5. 데이터베이스 접근 권한 관리 +6. 장애 및 보안 사고 대응을 위한 로그 점검 + +## 제13조 (개인정보 침해 관련 상담 및 신고) + +이용자는 개인정보 침해에 대한 상담이나 신고가 필요한 경우 아래 기관에 문의할 수 있습니다. + +- 개인정보침해신고센터: https://privacy.kisa.or.kr / 국번 없이 118 +- 개인정보분쟁조정위원회: https://www.kopico.go.kr / 1833-6972 +- 대검찰청 사이버범죄수사단: https://www.spo.go.kr / 국번 없이 1301 +- 경찰청 사이버범죄 신고시스템: https://ecrm.police.go.kr / 국번 없이 182 + +## 제14조 (개인정보 처리방침의 변경) + +운영팀은 관련 법령, 서비스 내용, 외부 서비스 이용 현황 변경에 따라 본 개인정보 처리방침을 변경할 수 있습니다. 변경되는 경우 서비스 내 약관 화면 또는 공지사항을 통해 변경 내용과 시행일을 안내합니다. +$privacy$, + content_format = 'markdown', + effective_at = '2026-06-02 00:00:00+09', + updated_at = now() +WHERE type = 'privacy_policy'; + +UPDATE terms +SET title = '서비스 이용방법', + content = $guide$ +# LinClean 서비스 이용방법 + +LinClean은 URL을 분석하여 위험 가능성을 확인하고, 자주 확인하거나 보관하고 싶은 링크를 저장할 수 있는 앱입니다. + +## 1. 로그인하기 + +1. 앱을 실행한 뒤 Google 계정으로 로그인합니다. +2. 로그인은 Clerk 인증 서비스를 통해 처리됩니다. +3. 로그인이 완료되면 URL 분석, 저장 링크, 카테고리 관리 기능을 이용할 수 있습니다. + +## 2. URL 분석하기 + +1. 홈 화면에서 분석하려는 URL을 입력합니다. +2. 앱 외부에서 링크를 공유하여 LinClean으로 전달할 수도 있습니다. +3. LinClean은 입력된 URL의 리다이렉트, 위협 데이터베이스, 도메인 신호, 페이지 분석 신호 등을 바탕으로 안전성을 분석합니다. +4. 분석이 완료되면 안전, 주의, 위험 중 하나의 결과와 요약 정보를 확인할 수 있습니다. + +## 3. 분석 결과 이해하기 + +- 안전: 현재 분석 기준에서 위험 신호가 낮게 확인된 URL입니다. +- 주의: 일부 위험 가능성 또는 확인이 필요한 신호가 발견된 URL입니다. +- 위험: 악성, 피싱, 사기, 멀웨어 등 위험 가능성이 높게 확인된 URL입니다. + +분석 결과는 참고 정보이며, 모든 위험을 완전하게 탐지하거나 안전을 보장하지 않습니다. 금융거래, 결제, 개인정보 입력이 필요한 사이트는 추가로 주소, 인증서, 공식 채널 여부를 확인해 주세요. + +## 4. 링크 저장하기 + +1. 분석 결과 화면에서 링크를 저장할 수 있습니다. +2. 저장한 링크는 저장 링크 목록에서 다시 확인할 수 있습니다. +3. 링크 제목을 수정하거나 북마크로 표시할 수 있습니다. +4. 필요하지 않은 저장 링크는 삭제할 수 있습니다. + +## 5. 카테고리 관리하기 + +1. 저장 링크를 카테고리별로 정리할 수 있습니다. +2. 카테고리를 생성, 이름 변경, 삭제할 수 있습니다. +3. 카테고리를 삭제해도 해당 카테고리에 있던 링크가 즉시 삭제되는 것은 아니며, 링크의 카테고리 연결만 해제될 수 있습니다. + +## 6. 회원 탈퇴하기 + +1. 설정 화면에서 회원 탈퇴 기능을 이용할 수 있습니다. +2. 회원 탈퇴를 요청하면 계정은 즉시 탈퇴 상태로 전환되고 서비스 이용이 제한됩니다. +3. 계정 정보, 저장 링크, URL 분석 이력, 카테고리, 알림 및 디바이스 토큰 등 회원에게 연결된 데이터는 탈퇴일로부터 30일간 보관될 수 있으며, 이후 정기적으로 영구 삭제됩니다. +4. 탈퇴 후 30일 동안은 동일 인증 계정의 재이용이 제한될 수 있습니다. + +## 7. 안전한 이용을 위한 안내 + +1. 출처가 불분명한 링크는 분석 결과와 관계없이 신중하게 접속해 주세요. +2. URL에 포함된 철자, 도메인, 경로가 공식 사이트와 다른지 확인해 주세요. +3. 로그인, 결제, 개인정보 입력을 요구하는 페이지는 공식 앱이나 공식 웹사이트를 통해 다시 확인해 주세요. +4. LinClean 분석 결과는 보조 정보이며 최종 판단은 이용자 본인이 해야 합니다. + +## 8. 문의하기 + +서비스 이용 중 문제가 있거나 개인정보 관련 문의가 있는 경우 아래 이메일로 연락해 주세요. + +- 고객문의: linclean2026@gmail.com +- 개인정보 문의: sunmin4142@gmail.com +$guide$, + content_format = 'markdown', + effective_at = '2026-06-02 00:00:00+09', + updated_at = now() +WHERE type = 'service_guide'; diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..e1183fc --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + ${LOG_DIR}/app.json + + ${LOG_DIR}/app.%d{yyyy-MM-dd}.json + 7 + + + + + + + ${DISCORD_WEBHOOK_URL:-} + + + + + ERROR + + + 0 + 512 + + + + + + + + diff --git a/src/test/java/com/linclean/FlywayMigrationIntegrationTest.java b/src/test/java/com/linclean/FlywayMigrationIntegrationTest.java index 1723ea4..1eb6f45 100644 --- a/src/test/java/com/linclean/FlywayMigrationIntegrationTest.java +++ b/src/test/java/com/linclean/FlywayMigrationIntegrationTest.java @@ -55,7 +55,7 @@ void shouldApplyInitialMigrationOnEmptyDatabase() { Integer.class ); - assertThat(installedMigrations).isEqualTo(1); + assertThat(installedMigrations).isGreaterThanOrEqualTo(2); assertThat(termsCount).isGreaterThanOrEqualTo(3); }