Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 5 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ dependencies {
exclude group: 'io.swagger.core.v3', module: 'swagger-annotations'
}

implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사용처 없는 것 같은데 확인하고 제거해주세요

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이메일 템플릿 사용시에 사용 예정인 의존성입니다

implementation 'software.amazon.awssdk:ses:2.29.46'

compileOnly 'org.projectlombok:lombok'

developmentOnly 'org.springframework.boot:spring-boot-devtools'
Expand All @@ -62,8 +65,9 @@ dependencies {

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
runtimeOnly 'org.postgresql:postgresql'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

의존성 중복됩니다 제거해주세요


testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

dependencyManagement {
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/com/dreamteam/alter/AlterApplication.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
package com.dreamteam.alter;

import com.dreamteam.alter.adapter.outbound.aws.ses.properties.AwsProperties;
import com.dreamteam.alter.application.email.properties.EmailAuthProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.retry.annotation.EnableRetry;
import org.springframework.scheduling.annotation.EnableAsync;

@SpringBootApplication
@EnableJpaAuditing
@EnableRetry
@EnableAsync
@EnableConfigurationProperties({EmailAuthProperties.class, AwsProperties.class})
public class AlterApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.dreamteam.alter.adapter.inbound.general.email.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "이메일 인증 코드 발송 요청")
public class SendEmailVerificationCodeRequestDto {

@NotBlank
@Email
@Schema(description = "인증할 이메일 주소", example = "user@example.com")
private String email;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.dreamteam.alter.adapter.inbound.general.email.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "이메일 인증 코드 검증 요청")
public class VerifyEmailVerificationCodeRequestDto {

@NotBlank
@Email
@Schema(description = "인증할 이메일 주소", example = "user@example.com")
private String email;

@NotBlank
@Pattern(regexp = "^[0-9]{6}$", message = "인증 코드는 6자리 숫자여야 합니다.")
@Schema(description = "수신한 인증 코드 6자리", example = "123456")
private String code;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.dreamteam.alter.adapter.inbound.general.email.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "이메일 인증 성공 응답")
public class VerifyEmailVerificationCodeResponseDto {

@Schema(description = "이메일 인증 세션 토큰", example = "a1b2c3d4-e5f6-4a09-8c13-1b2a3d4e5f6a")
private String verificationToken;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package com.dreamteam.alter.adapter.inbound.general.user.controller;

import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse;
import com.dreamteam.alter.adapter.inbound.general.email.dto.SendEmailVerificationCodeRequestDto;
import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeRequestDto;
import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeResponseDto;
import com.dreamteam.alter.adapter.inbound.general.user.dto.*;
import com.dreamteam.alter.domain.email.port.inbound.SendEmailVerificationCodeUseCase;
import com.dreamteam.alter.domain.email.port.inbound.VerifyEmailVerificationCodeUseCase;
import com.dreamteam.alter.domain.user.port.inbound.CreateSignupSessionUseCase;
import com.dreamteam.alter.domain.user.port.inbound.LoginWithPasswordUseCase;
import com.dreamteam.alter.domain.user.port.inbound.LoginWithSocialUseCase;
Expand Down Expand Up @@ -55,6 +60,13 @@ public class UserPublicController implements UserPublicControllerSpec {
@Resource(name = "resetPassword")
private final ResetPasswordUseCase resetPassword;

@Resource(name = "sendEmailVerificationCode")
private final SendEmailVerificationCodeUseCase sendEmailVerificationCode;

@Resource(name = "verifyEmailVerificationCode")
private final VerifyEmailVerificationCodeUseCase verifyEmailVerificationCode;


@Override
@PostMapping("/signup-session")
public ResponseEntity<CommonApiResponse<CreateSignupSessionResponseDto>> createSignupSession(
Expand Down Expand Up @@ -135,4 +147,22 @@ public ResponseEntity<CommonApiResponse<Void>> resetPassword(
resetPassword.execute(request);
return ResponseEntity.ok(CommonApiResponse.empty());
}

@Override
@PostMapping("/email/send")
public ResponseEntity<CommonApiResponse<Void>> sendVerificationCode(
@Valid @RequestBody SendEmailVerificationCodeRequestDto request
) {
sendEmailVerificationCode.execute(request);
return ResponseEntity.ok(CommonApiResponse.empty());
}

@Override
@PostMapping("/email/verify")
public ResponseEntity<CommonApiResponse<VerifyEmailVerificationCodeResponseDto>> verifyVerificationCode(
@Valid @RequestBody VerifyEmailVerificationCodeRequestDto request
) {
VerifyEmailVerificationCodeResponseDto response = verifyEmailVerificationCode.execute(request);
return ResponseEntity.ok(CommonApiResponse.of(response));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse;
import com.dreamteam.alter.adapter.inbound.common.dto.ErrorResponse;
import com.dreamteam.alter.adapter.inbound.general.email.dto.SendEmailVerificationCodeRequestDto;
import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeRequestDto;
import com.dreamteam.alter.adapter.inbound.general.email.dto.VerifyEmailVerificationCodeResponseDto;
import com.dreamteam.alter.adapter.inbound.general.user.dto.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
Expand Down Expand Up @@ -89,6 +92,10 @@ public interface UserPublicControllerSpec {
@ExampleObject(
name = "비밀번호 형식 오류",
value = "{\"success\": false, \"code\" : \"A014\", \"message\" : \"비밀번호는 8~16자 이내 영문, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다.\"}"
),
@ExampleObject(
name = "이메일 인증 세션 오류",
value = "{\"success\": false, \"code\" : \"A015\", \"message\" : \"이메일 인증 세션이 유효하지 않거나 만료되었습니다.\" }"
)
}))
})
Expand Down Expand Up @@ -215,4 +222,67 @@ public interface UserPublicControllerSpec {
})
ResponseEntity<CommonApiResponse<Void>> resetPassword(@Valid ResetPasswordRequestDto request);

@Operation(
summary = "이메일 인증 코드 발송",
description = "이메일로 6자리 인증 코드 발송"
)
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "인증 코드 발송 성공"
),
@ApiResponse(responseCode = "429", description = "요청이 너무 많음 (쿨다운)",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class),
examples = {
@ExampleObject(
name = "쿨다운 위반",
value = "{\"success\": false, \"code\" : \"E004\", \"message\" : \"요청이 너무 많습니다. 잠시 후 다시 시도해주세요.\"}"
)
}
)
),
@ApiResponse(responseCode = "500", description = "서버 에러",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class),
examples = {
@ExampleObject(
name = "이메일 전송 실패",
value = "{\"success\": false, \"code\" : \"E003\", \"message\" : \"이메일 전송에 실패했습니다.\"}"
)
}
)
)
})
ResponseEntity<CommonApiResponse<Void>> sendVerificationCode(@Valid SendEmailVerificationCodeRequestDto request);

@Operation(summary = "이메일 인증 코드 검증", description = "발송된 인증 코드를 검증합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "인증 코드 검증 성공"),
@ApiResponse(responseCode = "400", description = "실패 케이스",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class),
examples = {
@ExampleObject(
name = "인증 코드 만료/없음",
value = "{\"success\": false, \"code\" : \"E001\", \"message\" : \"인증 코드가 없거나 만료되었습니다.\"}"
),
@ExampleObject(
name = "인증 코드 불일치",
value = "{\"success\": false, \"code\" : \"E002\", \"message\" : \"인증 코드가 일치하지 않습니다.\"}"
),
@ExampleObject(
name = "인증 시도 횟수 초과",
value = "{\"success\": false, \"code\" : \"E005\", \"message\" : \"인증 시도 횟수를 초과했습니다. 코드를 다시 발송해주세요.\"}"
)
}
)
)
})
ResponseEntity<CommonApiResponse<VerifyEmailVerificationCodeResponseDto>> verifyVerificationCode(@Valid VerifyEmailVerificationCodeRequestDto request);


}
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,8 @@ public class CreateUserRequestDto {
@Schema(description = "생년월일", example = "YYYYMMDD")
private String birthday;

@NotBlank
@Schema(description = "이메일 인증 성공 후 받은 세션 토큰")
private String emailVerificationToken;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

가입 진행중인 회원의 정보 식별 시 회원가입 세션 ID 를 사용하고 있는데 이와 유사하게 이메일 세션 정보도 이메일 인증 세션 ID 식으로 필드 네이밍 변경하면 어떨까 싶은데 어떻게 생각하시나요?
emailVerificationToken-> emailVerificationSessionId


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.dreamteam.alter.adapter.outbound.aws.ses;

import com.dreamteam.alter.application.email.properties.EmailAuthProperties;
import com.dreamteam.alter.common.exception.CustomException;
import com.dreamteam.alter.common.exception.ErrorCode;
import com.dreamteam.alter.domain.email.port.outbound.EmailSenderPort;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.services.ses.SesClient;
import software.amazon.awssdk.services.ses.model.*;

@Slf4j
@Component
@RequiredArgsConstructor
public class SesEmailSenderAdapter implements EmailSenderPort {
Copy link
Contributor

@ysw789 ysw789 Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AWS SES도 FCM과 동일한 외부 API 클라이언트이므로 기존 프로젝트 관례에 맞게 application 레이어로 이동해 주세요.

기존 패턴 (FCM)
application/
└── notification/
└── FcmClient.java ← 외부 API 클라이언트를 application 하위에 배치

변경 후 (SES)
application/
└── email/
└── SesEmailSender.java ← adapter/outbound가 아닌 application 하위로

클래스 이름도 SesEmailSenderAdapter에서 Adapter 네이밍 제거하고 SesEmailSender로 변경해 주세요.


private final SesClient sesClient;
private final EmailAuthProperties emailProperties;

@Override
public void sendVerificationCode(String toEmail, String code) {
try {
String subject = "[ALTER] 이메일 인증 코드";
String bodyText = "인증 코드: " + code + "\n\n이 코드는 5분간 유효합니다.";

SendEmailRequest request = SendEmailRequest.builder()
.source(emailProperties.getFrom())
.destination(Destination.builder().toAddresses(toEmail).build())
.message(Message.builder()
.subject(Content.builder().data(subject).build())
.body(Body.builder()
.text(Content.builder().data(bodyText).build())
.build())
.build())
.build();

sesClient.sendEmail(request);
log.info("Sent verification email to: {}", toEmail);

} catch (SesException e) {
log.error("Failed to send SES email to {}: {}", toEmail, e.awsErrorDetails().errorMessage());
throw new CustomException(ErrorCode.EMAIL_VERIFICATION_SEND_FAILED);
} catch (Exception e) {
log.error("Unexpected error sending email to {}: {}",toEmail, e.getMessage());
throw new CustomException(ErrorCode.EMAIL_VERIFICATION_SEND_FAILED);
}

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.dreamteam.alter.adapter.outbound.aws.ses.config;

import com.dreamteam.alter.adapter.outbound.aws.ses.properties.AwsProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.ses.SesClient;
import software.amazon.awssdk.services.ses.SesClientBuilder;

@Configuration
@RequiredArgsConstructor
public class AwsSesConfig {

private final AwsProperties awsProperties;

@Bean
public SesClient sesClient() {
SesClientBuilder builder = SesClient.builder()
.region(Region.of(awsProperties.getRegion()));

if (StringUtils.hasText(awsProperties.getAccessKey()) && StringUtils.hasText(awsProperties.getSecretKey())) {
AwsBasicCredentials credentials = AwsBasicCredentials.create(
awsProperties.getAccessKey(),
awsProperties.getSecretKey()
);
builder.credentialsProvider(StaticCredentialsProvider.create(credentials));
}

return builder.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.dreamteam.alter.adapter.outbound.aws.ses.properties;

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;

@Getter
@Setter
@ConfigurationProperties(prefix = "aws")
public class AwsProperties {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저희는 속성 값 주입을 위해 Properties 클래스 생성 대신에 @Value 어노테이션으로 주입하고 있습니다 참조해서 수정해주세요

private String region;
private String accessKey;
private String secretKey;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.dreamteam.alter.adapter.outbound.email.persistence;

import com.dreamteam.alter.domain.email.entity.EmailSendLog;
import org.springframework.data.jpa.repository.JpaRepository;

public interface EmailSendLogJpaRepository extends JpaRepository<EmailSendLog, Long> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.dreamteam.alter.adapter.outbound.email.persistence;

import com.dreamteam.alter.domain.email.entity.EmailSendLog;
import com.dreamteam.alter.domain.email.port.outbound.EmailSendLogPort;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
@RequiredArgsConstructor
public class EmailSendLogRepositoryImpl implements EmailSendLogPort {

private final EmailSendLogJpaRepository jpaRepository;

@Override
public EmailSendLog save(EmailSendLog log) {
return jpaRepository.save(log);
}

@Override
public Optional<EmailSendLog> findById(Long id) {
return jpaRepository.findById(id);
}
}
Loading