Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8be0405
init: 프로젝트 초기 설정 추가
jhan0121 Dec 21, 2025
7dd27a5
이메일 기반 멀티 디바이스 인증 및 관리 기능 구현 (#3)
jhan0121 Dec 24, 2025
c1e87f8
jacoco 기반 테스트 커버리지 CI 구축 (#6)
jhan0121 Dec 24, 2025
1b5f44d
디바이스 삭제 기능 추가 (#7)
jhan0121 Dec 24, 2025
5412e66
등록한 디바이스 조회 기능 응답 형식 수정 (#9)
jhan0121 Dec 24, 2025
f6a58db
복습할 URL 저장 기능 추가 (#10)
jhan0121 Dec 24, 2025
0496449
swagger 기반 API 문서 작성 (#12)
jhan0121 Dec 26, 2025
2c9c56a
CI 대상 branch 설정 추가 (#13)
jhan0121 Dec 26, 2025
afce778
복습 대상 URL 이메일 전송 스케줄러 구현 (#19)
jhan0121 Dec 30, 2025
5e3cc60
로그 기능 추가 (#21)
jhan0121 Dec 31, 2025
3b049d0
test: MemberServiceTest#authenticateDevice 테스트 커버리지 보완 (#22)
jhan0121 Jan 1, 2026
f67a53a
flyway 기반 db 마이그레이션 의존성 추가 (#24)
jhan0121 Jan 2, 2026
b861baf
배포 스크립트 추가 (#31)
jhan0121 Jan 3, 2026
68ce9b6
feat: 모니터링을 위한 alloy 설정 추가 (#34)
jhan0121 Jan 5, 2026
4e610cd
Merge branch 'be/prod' into be/dev
jhan0121 Jan 5, 2026
39969af
배포 최적화 적용 (#36)
jhan0121 Jan 5, 2026
12cfe8d
배포 스크립트 오류 수정 (#38)
jhan0121 Jan 5, 2026
94b654b
Merge branch 'be/prod' into be/dev
jhan0121 Jan 5, 2026
69455b3
모니터링 설정 불일치 수정 (#40)
jhan0121 Jan 5, 2026
59312e0
모니터링 연결 오류 수정 (#43)
jhan0121 Jan 6, 2026
a7ccfca
Merge branch 'be/prod' into be/dev
jhan0121 Jan 6, 2026
d16ed1f
디바이스 인증 방식 헤더 마이그레이션 (Phase 1) (#46)
jhan0121 Jan 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions alloy/config.alloy
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,7 @@ prometheus.scrape "node" {
local.file_match "local_files" {
path_targets = [
{
__path__ = "/var/log/app/*.json",
env = "dev",
__path__ = "/app/log/*.log",
app = "recycle-study-server",
},
]
Expand Down Expand Up @@ -85,6 +84,6 @@ otelcol.processor.memory_limiter "default" {

otelcol.exporter.otlphttp "default" {
client {
endpoint = sys.env("TEMPO_HOST") + "/v1/traces"
endpoint = sys.env("TEMPO_HOST")
}
}
31 changes: 0 additions & 31 deletions alloy/docker-compose.yml

This file was deleted.

33 changes: 33 additions & 0 deletions docker-compose.prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,36 @@ services:
- .env
volumes:
- /app/log:/app/log
networks:
- observability

alloy:
image: grafana/alloy:latest
container_name: alloy
restart: unless-stopped
env_file:
- .env
volumes:
- ./alloy/config.alloy:/etc/alloy/config.alloy
- /app/log:/app/log
command:
- "run"
- "--server.http.listen-addr=0.0.0.0:12345"
- "--storage.path=/var/lib/alloy"
- "--stability.level=experimental"
- "/etc/alloy/config.alloy"
ports:
- "12345:12345" # Alloy UI
- "4317:4317" # OTLP gRPC
networks:
- observability

node-exporter:
image: quay.io/prometheus/node-exporter:latest
container_name: node-exporter
restart: unless-stopped
networks:
- observability

networks:
observability:
38 changes: 38 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,42 @@ services:
- "8080:8080"
env_file:
- .env
volumes:
- app_log:/app/log
networks:
- observability
depends_on:
mysql:
condition: service_healthy

alloy:
image: grafana/alloy:latest
container_name: alloy
restart: unless-stopped
env_file:
- .env
volumes:
- ./alloy/config.alloy:/etc/alloy/config.alloy
- app_log:/app/log
command:
- "run"
- "--server.http.listen-addr=0.0.0.0:12345"
- "--storage.path=/var/lib/alloy"
- "--stability.level=experimental"
- "/etc/alloy/config.alloy"
ports:
- "12345:12345" # Alloy UI
- "14317:4317" # OTLP gRPC
networks:
- observability

node-exporter:
image: quay.io/prometheus/node-exporter:latest
container_name: node-exporter
restart: unless-stopped
networks:
- observability

mysql:
container_name: recycle-study-mysql
image: mysql:8.4
Expand All @@ -29,6 +61,12 @@ services:
interval: 10s
timeout: 5s
retries: 5
networks:
- observability

volumes:
mysql_volume:
app_log:

networks:
observability:
11 changes: 11 additions & 0 deletions src/main/java/com/recyclestudy/common/annotation/AuthDevice.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.recyclestudy.common.annotation;

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

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthDevice {
}
20 changes: 20 additions & 0 deletions src/main/java/com/recyclestudy/common/config/WebMvcConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.recyclestudy.common.config;

import com.recyclestudy.common.resolver.DeviceAuthArgumentResolver;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {

private final DeviceAuthArgumentResolver deviceAuthArgumentResolver;

@Override
public void addArgumentResolvers(final List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(deviceAuthArgumentResolver);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.recyclestudy.common.resolver;

import com.recyclestudy.common.annotation.AuthDevice;
import com.recyclestudy.exception.UnauthorizedException;
import com.recyclestudy.member.domain.Device;
import com.recyclestudy.member.domain.DeviceIdentifier;
import com.recyclestudy.member.repository.DeviceRepository;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

@Component
@RequiredArgsConstructor
public class DeviceAuthArgumentResolver implements HandlerMethodArgumentResolver {

private final DeviceRepository deviceRepository;

@Override
public boolean supportsParameter(final MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthDevice.class);
}

@Override
public Object resolveArgument(
final MethodParameter parameter,
final ModelAndViewContainer mavContainer,
final NativeWebRequest webRequest,
final WebDataBinderFactory binderFactory
) {
final String headerIdentifier = Optional.ofNullable(webRequest.getHeader("X-device-Id"))
.orElseThrow(() -> new UnauthorizedException("디바이스 인증 헤더가 누락되었습니다"));

final DeviceIdentifier identifier = DeviceIdentifier.from(headerIdentifier);
final Device device = deviceRepository.findByIdentifier(identifier)
.orElseThrow(() -> new UnauthorizedException("유효하지 않은 디바이스입니다"));

if (!device.isActive()) {
throw new UnauthorizedException("인증되지 않은 디바이스입니다");
}

return identifier;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
Expand All @@ -33,10 +34,22 @@ public String authenticateDevice(

@DeleteMapping
@ResponseBody
public ResponseEntity<Void> deleteDevice(@RequestBody final DeviceDeleteRequest request) {
final DeviceDeleteInput input = DeviceDeleteInput.from(request.email(), request.deviceIdentifier(),
public ResponseEntity<Void> deleteDevice(
@RequestHeader(value = "X-Device-Id", required = false) String headerIdentifier,
@RequestBody final DeviceDeleteRequest request
) {
final String resolvedIdentifier = getResolvedIdentifier(request.deviceIdentifier(), headerIdentifier);

final DeviceDeleteInput input = DeviceDeleteInput.from(request.email(), resolvedIdentifier,
request.targetDeviceIdentifier());
memberService.deleteDevice(input);
return ResponseEntity.noContent().build();
}

private String getResolvedIdentifier(final String identifier, final String headerIdentifier) {
if (headerIdentifier == null) {
return identifier;
}
return headerIdentifier;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -41,11 +42,21 @@ public ResponseEntity<MemberSaveResponse> saveMember(@RequestBody final MemberSa
@GetMapping
public ResponseEntity<MemberFindResponse> findAllMemberDevices(
@RequestParam(name = "email") final String email,
@RequestParam(name = "identifier") final String identifier
@RequestParam(name = "identifier", required = false) final String identifier,
@RequestHeader(value = "X-Device-Id", required = false) String headerIdentifier
) {
final MemberFindInput input = MemberFindInput.from(email, identifier);
final String resolvedIdentifier = getResolvedIdentifier(identifier, headerIdentifier);

final MemberFindInput input = MemberFindInput.from(email, resolvedIdentifier);
final MemberFindOutput output = memberService.findAllMemberDevices(input);
final MemberFindResponse response = MemberFindResponse.from(output);
return ResponseEntity.ok(response);
}

private String getResolvedIdentifier(final String identifier, final String headerIdentifier) {
if (headerIdentifier == null) {
return identifier;
}
return headerIdentifier;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

Expand All @@ -21,10 +22,22 @@ public class ReviewController {
private final ReviewService reviewService;

@PostMapping
public ResponseEntity<ReviewSaveResponse> saveReview(@RequestBody ReviewSaveRequest request) {
final ReviewSaveInput input = request.toInput();
public ResponseEntity<ReviewSaveResponse> saveReview(
@RequestHeader(value = "X-Device-Id", required = false) String headerIdentifier,
@RequestBody ReviewSaveRequest request
) {
final String resolvedIdentifier = getResolvedIdentifier(request.identifier(), headerIdentifier);

final ReviewSaveInput input = ReviewSaveInput.of(resolvedIdentifier, request.targetUrl());
final ReviewSaveOutput output = reviewService.saveReview(input);
ReviewSaveResponse response = ReviewSaveResponse.of(output.url(), output.scheduledAts());
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

private String getResolvedIdentifier(final String identifier, final String headerIdentifier) {
if (headerIdentifier == null) {
return identifier;
}
return headerIdentifier;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -545,4 +545,41 @@ void deleteDevice_NullTargetIdentifier() {
.statusCode(HttpStatus.BAD_REQUEST.value())
.body("message", equalTo("null이 될 수 없습니다: value"));
}

@Test
@DisplayName("헤더로 디바이스 인증하여 삭제 시 204 응답을 반환한다")
void deleteDevice_WithHeader() {
// given
final String headerIdentifier = "device-id";
final DeviceDeleteRequest request = new DeviceDeleteRequest("test@test.com", null, "target-id");

doNothing().when(memberService).deleteDevice(any());

// when
// then
given(this.spec)
.filter(document(DEFAULT_REST_DOC_PATH,
builder()
.tag("Device")
.summary("디바이스 삭제")
.description("헤더로 디바이스 인증하여 삭제 시 204 응답을 반환한다")
.requestHeaders(
headerWithName("X-Device-Id").description("디바이스 식별자")
)
.requestFields(
fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"),
fieldWithPath("identifier").type(JsonFieldType.STRING)
.description("디바이스 식별자 (deprecated, 헤더 사용 권장)").optional(),
fieldWithPath("targetIdentifier").type(JsonFieldType.STRING)
.description("삭제할 디바이스 식별자")
)
))
.contentType(MediaType.APPLICATION_JSON_VALUE)
.header("X-Device-Id", headerIdentifier)
.body(request)
.when()
.delete("/api/v1/device")
.then()
.statusCode(HttpStatus.NO_CONTENT.value());
}
}
Loading