728x90

개요

스프링 배치를 사용해서 알림 재전송 기능을 구현했을 때 다양한 방식들의 트리거링 방식에 대해서 정리할려고 한다.

 

 

배치 트리거링(Batch Triggering)

 

  • 배치 트리거링(Batch Triggering)은 특정 조건이나 이벤트가 발생할 때 자동으로 배치 작업을 시작하는 메커니즘을 말한다.
  • 배치 작업은 대량의 데이터를 처리하거나 주기적으로 실행되는 작업을 자동화하는 데 사용되는데 이때 트리거링을 통해 작업 시작을 제어한다.

 

 

배치 트리거링 방식

  • 시간 기반 트리거(Time-based Trigger)
    • 특정 시간이나 주기로 배치 작업이 실행되도록 설정하는 방식이다.
    • 매일 자정에 데이터 백업 작업을 실행하는 경우
  • 이벤트 기반 트리거(Event-based Trigger)
    • 특정 이벤트가 발생할 때 배치 작업을 실행하는 방식이다.
    • 사용자가 주문을 완료하면 해당 주문 정보를 처리하는 배치 작업이 실행되는 경우
  • 조건 기반 트리거(Condition-based Trigger)
    • 특정 조건이 충족될 때 배치 작업을 실행하는 방식이다.
    • 데이터베이스에 일정한 양의 데이터가 쌓이면 이를 처리하는 배치 작업이 실행되는 경우

 

배치 트리거링은 시스템 효율성을 높이고 사용자가 수동으로 작업을 실행하지 않아도 자동으로 필요한 처리가 이뤄지도록 돕는 중요한 개념이다.!!!!

728x90
728x90

 

 

 

배치란? 스프링 배치란? 

  • 배치 는 일정 시간마다 또는 특정 조건에 따라 대규모 데이터를 일괄 처리하는 방식을 의미한다.
  • 스프링 배치(Spring Batch)는 이러한 배치 작업을 보다 쉽게 구현하고 관리할 수 있도록 돕는 프레임워크이다.
  • 스프링 배치는 스프링 생태계의 일환으로, 스프링의 강력한 기능을 활용하여 배치 작업을 안정적이고 효율적으로 수행할 수 있도록 지원한다.
  • 이를 통해 개발자는 복잡한 배치 처리 로직을 간결하게 구현하고 모니터링 기능을 통해 배치 작업을 효과적으로 관리할 수 있다.

 

스프링 배치와 스프링 스케줄러 차이

  • 많은 개발자들이 이 두 가지를 혼동하곤 하지만, 스프링 배치(Spring Batch)와 스프링 스케줄러(Spring Scheduler)는 서로 다른 목적과 기능을 가지고 있다.

 

스프링 배치

  • 대량의 데이터를 효율적으로 처리하기 위해 설계된 프레임워크
  • 복잡한 배치 작업을 구성하고 실행하기 위한 다양한 도구와 기능을 제공
    • 배치 프로세스를 주기적으로 커밋: 막강한 커밋 전략을 제공
    • 동시 다발적인 job 의 배치 처리, 대용량 병렬 처리
    • 실패 후 수동 또는 스케줄링에 의한 재시작: 오류에 대한 대처 옵션을 제공
    • 반복, 재시도, skip 처리 : 실패한 배치 작업을 중단된 지점부터 다시 시작하거나 skip 할 수 있다.
    • 모니터링: 배치 작업의 진행 상태와 소요 시간 등의 정보를 제공
    • 트랜잭션 관리: 트랜잭션 관리를 지원하여 데이터의 일관성 유지

 

스프링 배치는 주로 금융, 전자상거래, 건강관리 등 대량의 데이터 처리가 필요한 도메인에서 널리 사용된다.

배치(batch)는 데이터를 실시간으로 처리하는게 아니라, 일괄적으로 모아서 한번에 처리하는 작업을 의미한다.

 

 

스프링 스케줄러

  • 스프링 스케줄러는 특정 시간에 작업을 실행하기 위해 설계된 도구이다.
  • 반복적인 작업을 자동화하고, 시간 기반의 작업을 쉽게 설정할 수 있다.
    • 시간 기반 작업 실행: 특정 시간 또는 주기적으로 작업을 실행할 수 있다.
    • 다양한 스케줄링 방식: 크론 표현식(Cron Expression) 또는 Fixed Delay 를 통해 작업 스케줄을 설정할 수 있다.
    • 비동기 처리: 스케줄된 작업을 비동기적으로 실행하여 애플리케이션의 성능을 유지할 수 있다.
    • 유연한 구성: XML 설정 또는 애노테이션을 통해 쉽게 설정할 수 있다.

 

스프링 스케줄러는 주로 정기적인 백업, 이메일 알림, 데이터 동기화 등의 작업을 자동화하는 데 사용된다.

 

 

특징

                                              스프링 배치 (Spring Batch)                                         스프링 스케줄러 (Spring Scheduler)

목적 대량 데이터 일괄 처리 시간 기반 작업 실행
주요 기능 트랜잭션 관리, 재시작 및 중단, 데이터 검증 및 오류 처리 크론 표현식, 고정된 지연, 비동기 처리
사용 사례 금융 데이터 처리, 대량 파일 처리, 시스템 통합 정기 백업, 이메일 알림, 데이터 동기화
구성 방식 주로 XML 설정 또는 Java Config 애노테이션 또는 XML 설정

 

 

 


 

스프링 배치 구조

 

스프링배치 주요 흐름 이미지 출처 (https://terasoluna-batch.github.io/)

 

  1. 스프링 배치 (Spring Batch):
    • 스프링 배치는 대규모 데이터 처리 작업을 자바 기반의 배치 처리 어플리케이션을 만들기 위한 프레임워크
    • 대용량 데이터를 처리하는 경우나 주기적인 업무 일괄 처리 등에 사용
    • 스프링 배치는 배치 작업을 트랜잭션 처리와 함께 관리하고, 실패한 작업을 복구하고 재시작할 수 있는 기능을 제공
    • 잡(Job), 스텝(Step), 리더(Reader), 프로세서(Processor), 라이터(Writer) 등의 개념을 이용하여 배치 처리를 설계하고 실행한다.
    • job은 여러개의 step 으로 구성되고 step은 taskelt(기능) 으로 구성된다.
    • 나는 tasklet으로 구현하였다.!!
  2. 쿼츠 (Quartz):
    • 쿼츠는 자바 기반의 오픈 소스 스케줄링 라이브러리
    • 스케줄링 작업을 관리하고 실행할 수 있는 기능을 제공
    • 주기적으로 실행되어야 하는 작업이나 예약된 작업을 관리하고 실행하는 데 사용
    • 쿼츠는 크론 표현식을 사용하여 실행 스케줄을 설정하고, 다양한 트리거(trigger)를 통해 작업을 실행할 수 있음
    • 분산 환경에서도 사용할 수 있는 확장 가능한 아키텍처를 가지고 있어, 대규모 시스템에서도 유연하게 활용됨
요약하자면, 스프링 배치는 대규모 데이터 처리를 위한 일괄 처리 프레임워크이고, 쿼츠는 예약된 작업을 스케줄링하고 실행하는 데 사용되는 라이브러리 
-> 함께 사용되어 시스템에서 정기적인 작업 처리와 스케줄링을 효과적으로 관리할 수 있음

 

 

 

Job : 전체 배치 프로세스를 캡슐화한 엔티티, 배치 처리 과정을 하나의 단위로 만들어 놓은 객체

 

JobInstance : Job 실행의 단위(job이 각각 실행할 때마다 따로 추적할 수 있도록 매 실행마다 생성되며 특정 job과 식별 가능한 JobParameters에 상응하는 JobInstance는 단 한 개뿐이다.)

 

JobParameters : JobInstance에 전달되는 매개변수 역할, JobInstance를 구분하는 식별자 역할

 

JobExecution : Job에 대한 실행 시도 정보를 담은 객체.

 

Step : 배치 Job의 배치처리를 정의하고 순차적인 단계를 캡슐화 한 도메인 객체. Job은 하나 이상의 Step을 가져야 한다.

 

StepExecution : Step에 대한 실행 시도 정보를 담은 객체.

 

ExecutionContext : 프레임워크에서 유지/관리하는 key-value의 컬렉션. StepExecution객체 또는 JobExecution객체에 속하는 상태를 저장

 

JobExecutionContext : Commit 시점에 저장

 

StepExecutionContext : 실행 사이에 저장

 

JobRepository : 위에 업근된 모든 저장(Persistence)매커니즘을 담당.(JobLauncher, Job, Step 구현체에 CRUD 기능을 제공)

 

JobLauncher : 주어진 JobParameters로 Job을 실행하는 간단한 인터페이스.

 

- Step에서 아이템을 한 번에 하나씩 읽어오는 작업을 추상화한 개념

Tasklet 

   or

Item Reader 

Item Writer : Step에서 배치나 청크 단위로 아이템을 출력하는 작업을 추상화한다.

Item Processor : 아이템을 처리하는 비즈니스 로직을 나타내는 추상화 개념

 


 

https://github.com/spring-projects/spring-batch/wiki/Spring-Batch-5.0-Migration-Guide

 

Spring Batch 5.0 Migration Guide

Spring Batch is a framework for writing batch applications using Java and Spring - spring-projects/spring-batch

github.com

스프링배치5부터는 매인 애플리케이션에 @EnableBatchProcessing 어노테이션을 사용할 필요가 없다.

자세한 내용은 마이그레이션 가이드부터 읽어 보길 권장한다!!

 

 

728x90
728x90

개요

이전 블로그에 지연로딩, 즉시로딩에 대해서 살펴보았다.

JPA를 사용하다 보면 바로 N+1의 문제에 마주치고 바로 Fetch Join을 접하게 된다.
처음 Fetch Join을 접했을 때 왜 일반 Join으로 해결하면 안되는지에 대해 명확히 정리가 안된 채로 Fetch Join을 사용했다.
어떤 문제 때문에 일반 join으로 N+1을 해결하지 못하는지를 시작으로 해서 Fetch Join, Join의 차이점을 정리해보겠다.

 

 

Join, Fetch Join 차이점 요약

  • 일반 Join
    • Fetch Join과 달리 연관 Entity에 Join을 걸어도 실제 쿼리에서 SELECT 하는 Entity는 오직 JPQL에서 조회하는 주체가 되는 Entity만 조회하여 영속화
    • 조회의 주체가 되는 Entity만 SELECT 해서 영속화하기 때문에 데이터는 필요하지 않지만 연관 Entity가 검색조건에는 필요한 경우에 주로 사용됨

 

  • Fetch Join
    • 조회의 주체가 되는 Entity 이외에 Fetch Join이 걸린 연관 Entity도 함께 SELECT 하여 모두 영속화
    • Fetch Join이 걸린 Entity 모두 영속화하기 때문에 FetchType이 Lazy인 Entity를 참조하더라도 이미 영속성 컨텍스트에 들어있기 때문에 따로 쿼리가 실행되지 않은 채로 N+1문제가 해결됨

 

 

N+1 문제

단순 테이블 조회 쿼리를 하나 생성하였는데, 연관관계에 있는 데이터를 모두 불러오기 위해서 select 쿼리가 테이블의 레코드 수(혹은 호출한 데이터 수) 만큼 더 생성되는 문제를 의미한다.

 

즉시로딩은 최초 테이블 데이터를 모두 불러온 후 바로 연관관계가 있는 엔티티를 조회하는 방식을,

지연로딩은 최초 테이블 데이터를 모두 불러온 후 필요한 시점에 연관관계가 있는 엔티티를 조회하는 방식을 채택한다.

 

즉시로딩은 워딩 그대로 조회 시점에 연관관계에 있는 엔티티가 모두 조회되어 N+1 문제가 생긴다는 것을 직관적으로 알 수 있지만 지연로딩은 얼핏보면 로딩 시점에 쿼리가 더 나가지 않아 N+1 문제가 발생하지 않을 것으로 보인다. 하지만 지연로딩 N+1 문제를 피해갈 수는 없는데, 그 이유는 연관관계에 있는 엔티티가 조회 시점에 영속화되지 않았기 때문이다.

 

일반적으로 JPA는 JPQL 로 생성된 쿼리문을 데이터베이스와 connection 을 만든 후에 전송하고, 이에 대한 결과 데이터를 전달받아 실제 엔티티 객체를 생성하고 이를 영속화 한다.

 

 

 

예시

@Entity
class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;
    
    String title;

    @OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
    List<Comment> cpmments = ArrayList()
}

@Entity
class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;
    
    String content;

    @ManyToOne(fetch = FetchType.LAZY)
    Post post;
}
@Service
@RequiredArgsConstructor
public class PostService {

    private final PostRepository postRepository;

    @Transactional(readOnly = true)
    public void getMockposts() {
        postRepository.findAll().forEach(post -> {
            post.getpPostComments().forEach(cpmments ->
                System.out.println(cpmments.getContent())
            );
        });
    }
}

위 코드에서 N+1 문제가 발생할 수 있다. 예를 들어, 100개의 게시글이 있다고 가정하면, PostRepository의 findAll() 메서드가 호출될 때 모든 게시글을 조회한다. 이때, 각 게시글의 댓글을 조회하기 위해 추가로 100번의 쿼리가 실행다. 이는 총 101번의 쿼리 실행으로 이어지며, 매우 비효율적인 방법이다. 즉, 다음과 같이 SQL이 실행되면서 N+1 문제가 발생한 것을 알 수 있다.

SELECT * FROM post;
SELECT * FROM comment WHERE post_id = 1;
SELECT * FROM comment WHERE post_id = 2;
SELECT * FROM comment WHERE post_id = 3;
...
SELECT * FROM comment WHERE post_id = 99;
SELECT * FROM comment WHERE post_id = 100;

 

 

해결 방법

1. Fetch Join (@Query 어노테이션 사용)

  • Fetch Join은 N+1 문제를 방지하고 성능을 최적화할 수 있는 수단으로서 데이터베이스에서 연관된 엔터티를 함께 로딩하는 방법이다.
  • Fetch Join을 사용하면 조회 성능을 최적화 할 수 있다.
  • 연관된 엔티티를 쿼리 시 함께 조회하는 방법이다.
  • SQL 쿼리에 직접 Join fetch를 명시한다.
  • 조회의 주체가 되는 엔티티 외에 Fetch Join이 걸린 연관 엔티티도 같이 영속화 시켜준다.
  •  
@Repository 
public interface PostRepository extends JpaRepository<Post, String> {
@Query("SELECT p from Post p JOIN FETCH p.comments")
List<Post> findAllPostsWithComments();
}
  •  "JOIN FETCH p.comments" 구문을 통해서 게시글을 조회할 때 댓글 엔티티도 함께 조회한다.
  • join fetch는 Fetch Join을 사용하여 연관된 데이터를 조회하겠다는 뜻이다.

 

2. Entity Graph

  • EntityGraph를 사용하면 Fetch Join를 사용한 경우와 마찬가지로 연관된 엔티티나 컬렉션을 같이 조회 할 수 있다.
  • JPQL과 함께 사용하며 attributePaths에 쿼리 수행 시 조회할 필드를 지정해준다.
  • 지정된 필드는 지연 로딩이 아닌 즉시 로딩으로 조회된다.
  • EntityGraph는 Fetch Join과는 다르게 Outer Join이 사용되어 동작한다
@Repository 
public interface PostRepository extends JpaRepository<Post, String> {

@EntityGraph(
        attributePaths = {"comments"},
        type = EntityGraph.EntityGraphType.LOAD
    )
@Query("SELECT p FROM post p")
List<Post> findAllPostsWithComments();

}

 

 

Fetch Join 및 EntityGraph 사용 시 발생하는 카테시안 곱 문제

Fetch Join 또는 EntityGraph를 사용한 경우엔 카테시안 곱(Cartesian Product)으로 인하여 중복 문제가 발생할 수 있습니다. 카테시안 곱은 연관관계의 엔티티에 Join 규칙을 사용하지 않아 결합 조건이 주어지지 않았을 때 모든 데이터의 경우의 수를 결합(M * N)하여 반환하는 문제입니다. 즉, 조인 조건이 없는 경우에 대한 두 테이블의 결합 결과를 반환해야 하기 때문에 존재하는 모든 행의 개수를 곱한 결과를 반환하게 됩니다. 카테시안 곱 문제를 해결하기 위해서는 다음과 같은 방법을 사용합니다.
 

 

 


1. JPQL에 DISTINCT 사용하기


JPQL을 이용한 쿼리를 사용할 때 다음과 같이 DISTINCT를 사용하여 중복을 제거합니다.

@Repository 
public interface PostRepository extends JpaRepository<Post, String> {

    @Query("SELECT DISTINCT p from Post p JOIN FETCH p.comments")
	List<Post> findAllPostsWithComments();
    
    @EntityGraph(
        attributePaths = {"comments"},
        type = EntityGraph.EntityGraphType.LOAD
    )
	@Query("SELECT DISTINCT p FROM post p")
	List<Post> findAllPostsWithComments();
}


 

2. 연관관계의 필드 타입에 Set 사용하기

  • Set은 중복을 허용하지 않기 때문에 중복 데이터가 들어가지 않지만 순서가 보장되지 않는다.
  • 이러한 경우 다음과 같이 연관관계의 필드에 LinkedHashSet을 사용하여 중복을 제거하고 순서를 보장 해줄 수 있다.
@Getter
@NoArgsConstructor
@Entity(name = "posts")
public class post {

    @OneToMany(mappedBy = "post")
    private Set<Comment> comments = new LinkedHashSet<>();

}

 

 

728x90
728x90

🧐 문제 발생 상황

OAuth2 소셜로그인을 하면 생일, 성별, 연락처에 대한 정보는 가져올 수가 없어서 추가 정보 기입 폼을 만들어서 디비에 저장되도록 구현했다.

그래서 생각한게 어느 블로그에서 본건데 Setter보다 @Builder 패턴을 사용하는게 좋다해서 소셜로그인 후 추가 정보 입력에 적용해보았다. 그래서 아래와 같은 코드로 실행을 했더니

// entity
@Getter 
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Oauth2LoginDto {

    private String phoneNumber; // 연락처
    private LocalDate birthdate; // 생년월일
    private String gender; // 성별
}
    // service
    
    /**
     * Oauth2 로그인 시 추가 정보 입력
     */
    @Override
    public Optional<User> updateOauth2(String username, Oauth2LoginDto oauth2LoginDto) {
        
        User user = User.builder()
        			.phoneNumber(oauth2LoginDto.getPhoneNumber())
				.gender(oauth2LoginDto.getGender())
				.birthdate(oauth2LoginDto.getBirthdate())
				.build();
        userRepository.save(user);
        return Optional.of(user);
    }

아래와 같은 오류가 발생했다.

org.springframework.dao.DataIntegrityViolationException: not-null property references a null or transient value : com.example.omg_project.domain.user.entity.User.nam

저 오류에 대해서 검색하니

 DataIntegrityViolationException 예외는 JPA가 엔티티를 저장하는 도중 User 엔티티의 name 필드가 null로 설정되어 있는 것을 감지했기 때문에 발생한 것이다. name 필드는 not-null 제약 조건이 설정되어 있어서 이로 인해 Hibernate가 엔티티를 데이터베이스에 저장할 수 없었다.

라고 한다.

 DataIntegrityViolationException 예외는 JPA가 엔티티를 저장하는 도중 User 엔티티의 name 필드가 null로 설정되어 있는 것을 감지했기 때문에 발생한 것이다. name 필드는 not-null 제약 조건이 설정되어 있어서 이로 인해 Hibernate가 엔티티를 데이터베이스에 저장할 수 없었다.

 

빌더 패턴을 사용해서 구현할 거면 아래와 같이 기존 사용자가 notnull로 갖고 있는 필드들에 대해서 다 값을 복사해줘야 한다는 것이다.. 그래서 아니 세터보다 빌더를 사용하라면서 왜 저렇게 굳이 저래야 하나..? 했는데 열심히 구글링을 해서 빌더와 세터의 차이에 대해서 확실히 이해가 갔다.

/**
 * Oauth2 로그인 시 추가 정보 입력
 */
@Override
public Optional<User> updateOauth2(String username, Oauth2LoginDto oauth2LoginDto) {

    User user = User.builder()
				.name(user.getName()) // 기존에 있던 값 추가
				.phoneNumber(oauth2LoginDto.getPhoneNumber())
				.gender(oauth2LoginDto.getGender())
				.birthdate(oauth2LoginDto.getBirthdate())
				.build();
    userRepository.save(user);
    return Optional.of(user);
}

 

이러한 문제가 발생된 원인은 내가 빌더패턴을 정확하게 이해하지 못해서 발생한 대참사이다. 빌더는 객체를 수정하는 것이 아니라 생성자처럼 객체를 생성하는 기능을 수행한다고 한다.

 

 

정확하게 알아야 할 개념

  • 빌더패턴은 객체의 생성을 담당하는 패턴으로, 일반적으로 빌더를 사용하여 객체를 새로 생성한다. 빌더를 사용하여 객체를 생성할 때, 이전 객체의 값을 수정하는 것이 아니라 새로운 객체를 생성하는 것이다.
  • 이것이 빌더 패턴의 주요 특징 중 하나이며, 이를 이해하는 것이 중요하다.
  • 빌더 패턴은 객체 생성 시에 여러 속성을 가진 객체를 편리하게 생성하기 위해 사용된다. 따라서 객체의 속성을 일부 변경하거나 해당 속성을 지정하여 새로운 객체를 생성해야한다고 한다.
  • 따라서 내가 dto로 받아온 값을 User 엔티티의 빌더 패턴으로 엔티티 객체로 변환하려고 하는 행위(?)는 잘못되었다고 할 수 있다.
  • 하지만 빌더 패턴을 사용해서 객체의 데이터를 수정하는 것이 아예 불가능한 것은 아니라고 한다.

 

 

그렇다면 빌더 패턴으로 어떻게 값을 수정할 수 있는가?

// entity
@Getter  @Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder(toBuilder = true) // 기존 인스턴스를 기반으로 새로운 객체를 빌드할 수 있게 함
public class Oauth2LoginDto {

    private String phoneNumber; // 연락처
    private LocalDate birthdate; // 생년월일
    private String gender; // 성별
}

이처럼 toBuilder 속성을 true 로 설정해줄 경우 toBuilder() 메서드를 사용할 수 있게되는데

이 메서드를 사용하면 이미 생성되어있는 객체의 값을 toBuilder() 메서드를 활용하여 특정 필드의 값만을 변경해줄 수 있다고 한다.

 

 

결론

toBuilder 메서드를 활용하면 객체의 값을 일부 수정할 수는 있지만 내가 구현하려는 로직에서는 사용하기에 적합하지 않은 것 같다는 생각이 들었다.

어쨌든 빌더패턴이라는 것이 매개변수가 많아 생성자에 정의된 순서대로 데이터를 넣기 어려워 이를 편하게 하기 위한 디자인패턴인 건데 즉, 객체를 생성하는 것이 주된 목적인데 나는 객체의 값을 수정하는 로직을 만들어야 하므로 객체의 값을 부분적으로 수정하기 위해서는 굳이 엔티티 객체로 바꿀 필요는 없을 것 같고, dto에서 값을 꺼내서 entity에 setter 를 사용해서 update를 해주는 방식으로 구현하는 것이 적합한 것 같다.

 

 

✅ 정리

  • 세터 사용 방법: 기존 사용자 객체를 업데이트하는 방식이므로, 코드가 간결하고 오류 발생 가능성이 적다. 또한, 필수적으로 설정해야 하는 필드를 실수로 놓칠 가능성도 적다.
  • 빌더 사용 방법: 만약 객체의 다른 필드를 변경하거나 새로운 객체로 교체해야 하는 상황이 아니라면, 굳이 빌더 패턴을 사용할 필요는 없다. 이 방법은 필요 이상으로 복잡할 수 있으며, 잘못된 필드 값을 복사하거나 누락할 위험이 있다.
  • 즉, 빌더 패턴은 새로운 객체를 생성하거나 객체의 불변성을 유지해야 할 때 더 유용하다. 하지만 여기서는 기존 객체에 OAuth2 유저의 성별, 생일, 연락처의 정만 업데이트하는 것이므로 세터방식을 사용하는 것이 더 자연스럽고 안전하다.
728x90
728x90

개요

프로젝트 중

@Transactional 을 안 붙여서 오류가 나는 경우가 종종 있어서 이번 기회에 정리해놓을려고 한다.


@Transactional

 

Q. @Transactional 어노테이션 붙이는 이유?

  • 데이터베이스의 상태를 변경하는 메서드나 복수의 데이터베이스 작업을 묶어서 처리할 때 사용한다.
  • 데이터를 데이터베이스에 저장하거나 삭제, 수정하는 메서드처럼 데이터베이스의 상태를 변경하는 경우에 사용한다.

 

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final RoleRepository roleRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public void registerUser(String username, String email, String password, String passwordCheck, String usernick) throws IOException {
        
        Role role = roleRepository.findByRoleName(RoleName.ROLE_USER)
                    .orElseThrow(() -> new UserNotFoundException("User 역할이 없습니다."));
 
        User user = new User();
        user.setRole(Collections.singleton(role));  // singleton -- 단일 역할 설정
        user.setUserName(username);
        user.setEmail(email);
        user.setPassword(passwordEncoder.encode(password));
        user.setUserNick(usernick);

        userRepository.save(user);
    }

 @Transactional

  • 메서드가 실행되는 동안 트랜잭션을 시작하고, 메서드가 정상적으로 완료되면 커밋(commit)을 하고, 예외가 발생하면 롤백(rollback)을 수행한다.
  • 이 어노테이션을 사용하여 데이터베이스 관련 작업을 안전하게 처리할 수 있다.
  • 기본 값 : @Transactional(readOnly = false)
    • 특징: 위의 예시에서는 registerUser 메서드에 적용되어 있으며, readOnly = false로 설정되어 있어 해당 메서드는 읽기/쓰기 모두 가능한 트랜잭션을 사용합니다.

 

readOnly 속성의 차이점

  1. readOnly = true
    • 이 옵션을 설정하면, 트랜잭션 동안 데이터를 수정하는 쿼리(INSERT, UPDATE, DELETE 등)를 수행할 수 없다.
    • 오직 읽기 전용 쿼리(SELECT)만 가능
    • 데이터를 조회만 할때, 전체 사용자, 게시글 조회 등등
    • 이는 데이터베이스에게 현재 트랜잭션이 데이터를 변경하지 않을 것임을 알려 성능 최적화를 도와준다.
    • 읽기 전용 트랜잭션은 데이터베이스의 트랜잭션 로그를 기록하지 않아도 되어 I/O 부하를 줄일 수 있다.
  2. readOnly = false (기본값)
    • 읽기 및 쓰기 작업 모두를 수행할 수 있는 트랜잭션
    • 데이터베이스에 변경 사항이 발생하면 트랜잭션 로그 기록
    • 쓰기 작업이 필요한 경우에는 이 옵션을 사용
    • 삭제, 등록, 수정 등 데이터베이스의 상태를 변경할 때 사용한다.

 

결론 

데이터베이스에서 삽입, 삭제, 수정 등 데이터베이스의 내용이 변하게 된다면 @Transactional 붙여야 한다.!!

728x90
728x90

개요

프로젝트 중 관리자 부분도 하기로 해서 구현해보고자 한다.

 

일단 관리자와 일반 유저를 나눴기 때문에 권한에 따라 접근할 수 있는 경로는 
1. SecurityConfig.java 에서 hasRole or hasAuthority 를 통해 접근 경로를 제한한다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // 모든 유저 허용 페이지
    String[] allAllowPage = new String[] {
            "/",  "/de",      // 메인페이지
            "/signup", // 회원가입 페이지
            "/login", // 로그인 페이지
    };

    // 관리자 페이지
    String[] adminAllowPage = new String[] {
            "/admin",
            "/admin/**"
    };

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorize -> authorize
                        .requestMatchers(allAllowPage).permitAll()
                        .requestMatchers(adminAllowPage).hasRole("ADMIN")
                        .anyRequest().authenticated()
                )
                .addFilterBefore(new RedisJwtFilter2(jwtUtil, jwtBlacklistService, refreshTokenService), UsernamePasswordAuthenticationFilter.class)
                .formLogin(form -> form.disable());
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
 
    }

코드가 길어져서 불필요한 코드는 삭제했다. 

일단 나는 DB에
ROLE_ADMIN, ROLE_USER 형태로 사용자를 나눠서 저장했기 때문에 hasRole를 사용했다.

 

hasRole  과 hasAuthority 의 차이

  • hasAuthority
    • 권한을 부여할 때 역할이 아니라 권한 자체를 기준으로 접근을 제어
    • 직접 권한을 설정
    • hasAuthority("ROLE_ADMIN") 형태로 권한을 정확하게 명시해줘야 한다.
  • hasRole
    • 내부적으로 ROLE_ 접두사를 자동으로 붙인다.
    •  HTTP 요청 URL 패턴에 대해 접근 권한을 설정
    • 그래서 DB에 ROLE_ADMIN이라고 명시되어 있기 때문에 hasRole("ADMIN")이라고 명시를 한다.
@Controller
@RequiredArgsConstructor
public class AdminController {

    private final UserService userService;
    private final JwtTokenizer jwtTokenizer;

    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/admin/userboard")
    public String adminPageAllUserForm(Model model, HttpServletRequest request) {

        model.addAttribute("users", userService.findAllUsers());
        String accessToken = jwtTokenizer.getAccessTokenFromCookies(request);
        if (accessToken != null) {
            String username = jwtTokenizer.getUsernameFromToken(accessToken);
            User user = userService.findByUsername(username).orElse(null);
            model.addAttribute("user", user);
        }
        return "user/admin-all-user";
    }

또한 이미 configure 메서드에서 권한을 제한했지만 더욱 명확하게 하기위해 메서드 호출 전에 다시한번 확인차 

 @PreAuthorize("hasRole('ADMIN')")

어노테이션을 붙여주는 것이다.

  • @PreAuthorize("hasRole('ADMIN')")
    • 주로 메소드에서 사용
    • 특정 메소드에 접근할 수 있는 권한을 세밀하게 설정할 때 사용
    • 이 어노테이션은 메소드 호출 전에 현재 사용자의 권한을 확인하여, 지정된 역할(ADMIN)이 있는지 체크
    • 해당 역할이 없으면 메소드 호출이 거부

 

이렇게 권한을 제한했고 일반 사용자가 "/admin/**") url에 접근할 시 접근이 제한되게 된다. 이제 본격적으로 관리자 페이지를 구현해보겠다.

728x90
728x90

개요

기존 이미지 업로드는 로컬에서 임의로 파일을 만들어서 구현했지만 이제는 AWS S3를 사용해서 안전하게 이미지를 저장해볼려고 한다.
용도 : 회원정보를 수정할 때 프로필 이미지를 등록

 

1. Spring Boot에서 S3에 파일을 업로드하는 세 가지 방법

S3에 파일을 업로드하는 방법에는 3가지가 있다.

  • Stream 업로드
  • MultipartFile 업로드
  • AWS Multipart 업로드

1.1 Stream 업로드

Stream 업로드 방식은 파일을 chunk 단위로 읽어서 서버에 전송하는 방식이다. 직렬화된 데이터를 순차적으로 보내므로, 대용량 파일을 안정적으로 전송할 수 있지만, 각 chunk를 순차적으로 전송하기 때문에 전체 파일을 한 번에 업로드하는 방식보다 더 많은 시간이 소요될 수 있다.

1.2 MultipartFile 업로드

MultipartFile 업로드는 Spring에서 제공하는 MultipartFile 인터페이스를 이용하여 파일을 업로드하는 방식이다. 대부분의 웹 개발 프레임워크 및 라이브러리에서 기본적으로 지원하는 방식이므로 구현이 간단하다. 또한 사용자가 파일을 선택하고 업로드 버튼을 클릭하는 것으로 파일을 서버에 업로드할 수 있다. 드어마 파일 전체를 메모리에 로드하므로, 대용량 파일을 처리할 때 메모리 부담이 있을 수 있다. 또한 파일을 서버에 업로드할 때 보안 취약성이 발생할 수 있다.

1.3 AWS Multipart 업로드

AWS Multipart 업로드는 AWS S3에서 제공하는 파일 업로드 방식으로 업로드할 파일을 작은 part로 나누어 개별적으로 업로드한다. 파일의 바이너리가 Spring Boot를 거치지 않고 AWS S3에 다이렉트로 업로드되기 때문에 서버의 부하를 고려하지 않아도 된다. 업로드 중에 오류가 발생해도 해당 부분만 재시도할 수 있다. 그러나 복잡한 구현을 필요로 하고 여러 요청을 병렬로 보내므로, 이에 따른 네트워크 및 데이터 전송 비용이 발생할 수 있다.

 

  • 원래 Presigned URL을 이용하는 3번 방식을 통해 구현해보고 싶었으나 다음과 같은 이유로 2번을 사용하려고 한다.
  • 3번의 경우 클라이언트로 부터 파일 업로드 요청을 받으면 프론트는 다시 백엔드로 그 파일을 전달해주고 백엔드에서 다시 S3로 업로드하는 불필요한 과정이 발생한다.
  • 프로젝트 규모가 현재 크지 않고, 빠르게 기능 구현을 해야하는 상황에서 나만의 호기심으로 사용해보기엔 구현 복잡도가 높다.
  • 따라서 현실과 타협해 Multipart 파일을 S3에 업로드하는 방법을 통해 이미지 업로드 기능을 구현하려고 한다.

 

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

spring Security 6 

 

 

 

개발 환경 및 S3 버킷 생성

[Spring] S3 이미지 업로드하기 (velog.io)

S3 에 업로드하기 위해서 aws 가입과 S3 발급을 해야 하는데 나는 이분의 블로그를 보고 따라했다. iam 사용자를 만들고 s3 키를 발급받자.

 

 

의존성 설치

// s3
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

 

 

 

필요한 환경변수 세팅

application.yml 파일에 다음과 같이 작성한다.

spring:
   servlet:
      multipart:
          maxFileSize: 10MB # 파일 하나의 최대 크기
          maxRequestSize: 30MB  # 한 번에 최대 업로드 가능 용량
cloud:
  aws:
    credentials:
      access-key: {your access-key}
      secret-key: {your secret-key}
    s3:
      bucket: {your bucket name}
    region:
      static: ap-northeast-2
    stack:
      auto: false
  • MultipartFile를 사용하기로 했으니 적절한 파일 크키와 용량을 지정하고 S3 연동을 위한 설정을 해준다.

 

 

S3Config

@Configuration
public class S3Config {
    @Value("${cloud.aws.credentials.access-key}")
    private String accessKey;
    @Value("${cloud.aws.credentials.secret-key}")
    private String secretKey;
    @Value("${cloud.aws.region.static}")
    private String region;

    @Bean
    public AmazonS3Client amazonS3Client() {
        BasicAWSCredentials awsCredentials= new BasicAWSCredentials(accessKey, secretKey);
        return (AmazonS3Client) AmazonS3ClientBuilder.standard()
                .withRegion(region)
                .withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
                .build();
    }
}
  • 버전이 올라가면서 작성법이 많이 달라져서 고생을 좀 했지만 엑세스와 시크릿 키를 통해 접근을 허용해주면 된다.

 

 

 

이미지 S3 업로드 구현

@Slf4j
@Service
@RequiredArgsConstructor
public class S3Service {

    private final AmazonS3 amazonS3;

    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    private final String DIR_NAME = "{your image file name";

    public String upload(String fileName, MultipartFile multipartFile) throws IOException {
        File uploadFile = convert(multipartFile)
                .orElseThrow(() -> new IllegalArgumentException("MultipartFile -> File 전환 실패"));

        String newFileName = DIR_NAME + "/" + fileName + getExtension(multipartFile.getOriginalFilename());
        String uploadImageUrl = putS3(uploadFile, newFileName);

        removeNewFile(uploadFile);  // 로컬에 생성된 파일 삭제
        return uploadImageUrl;      // 업로드된 파일의 S3 URL 주소 반환
    }

    private String putS3(File uploadFile, String fileName) {
        amazonS3.putObject(new PutObjectRequest(bucket, fileName, uploadFile)
                .withCannedAcl(CannedAccessControlList.PublicRead));
        return amazonS3.getUrl(bucket, fileName).toString();
    }

    private void removeNewFile(File targetFile) {
        if (targetFile.delete()) {
            log.info("파일이 삭제되었습니다.");
        } else {
            log.info("파일이 삭제되지 못했습니다.");
        }
    }

    private Optional<File> convert(MultipartFile file) throws IOException {
        File convertFile = new File(file.getOriginalFilename());
        if (convertFile.createNewFile()) {
            try (FileOutputStream fos = new FileOutputStream(convertFile)) {
                fos.write(file.getBytes());
            }
            return Optional.of(convertFile);
        }
        return Optional.empty();
    }

    private String getExtension(String fileName) {
        return fileName.substring(fileName.lastIndexOf("."));
    }
  • S3 에 이미지를 업로드 하는 클래스이다.
  • 각 함수들에 대해서 설명해보겠다.

1. upload 함수

public String upload(String fileName, MultipartFile multipartFile) throws IOException {
        File uploadFile = convert(multipartFile)
                .orElseThrow(() -> new IllegalArgumentException("MultipartFile -> File 전환 실패"));

        String newFileName = DIR_NAME + "/" + fileName + getExtension(multipartFile.getOriginalFilename());
        String uploadImageUrl = putS3(uploadFile, newFileName);

        removeNewFile(uploadFile);  // 로컬에 생성된 파일 삭제
        return uploadImageUrl;      // 업로드된 파일의 S3 URL 주소 반환
    }
}
  • 여기서 MutipartFile을 File 형태로 변환해주어야 한다.
  • 이 과정에서 로컬에서 파일이 복사되어 저장되기 때문에 로컬에 있는 이미지를 삭제해주어야 한다. 

2. putS3 메서드

    private String putS3(File uploadFile, String fileName) {
        amazonS3.putObject(new PutObjectRequest(bucket, fileName, uploadFile)
                .withCannedAcl(CannedAccessControlList.PublicRead));
        return amazonS3.getUrl(bucket, fileName).toString();
    }
  • 이 함수는 MultipartFile형태인 파일을 File 형태로 변환하고 업로드될 파일의 이름을 받아와서 S3에 업로드 하는 함수이다.

3. removeNewFile  메서드

    private void removeNewFile(File targetFile) {
        if (targetFile.delete()) {
            log.info("파일이 삭제되었습니다.");
        } else {
            log.info("파일이 삭제되지 못했습니다.");
        }
    }
  • 이 함수는 로컬에 저장된 파일을 삭제해주는 메서드이다.

4. Convert  메서드

    private Optional<File> convert(MultipartFile file) throws IOException {
        File convertFile = new File(file.getOriginalFilename());
        if (convertFile.createNewFile()) {
            try (FileOutputStream fos = new FileOutputStream(convertFile)) {
                fos.write(file.getBytes());
            }
            return Optional.of(convertFile);
        }
        return Optional.empty();
    }
  • 이 함수는 MultipartFile 형태인 파일을 File 형태로 변환해서 S3에 업로드 하는 메서드이다.

5. getExtension  메서드

    private String getExtension(String fileName) {
        return fileName.substring(fileName.lastIndexOf("."));
    }
  • S3에 저장될 파일의 확장자를 정의해준다.

 

 

UserServiceImpl.java

@Override
public User updateProfileImage(Long userId, MultipartFile file) throws IOException {
    User user = userRepository.findById(userId)
            .orElseThrow(() -> new IllegalArgumentException("유효하지 않은 사용자 ID"));

    String fileName = userId.toString(); // 사용자 ID를 파일명으로 사용
    String imageUrl = s3Service.upload(fileName, file); // S3에 이미지 업로드 및 URL 반환

    user.setFilename(file.getOriginalFilename()); // 파일 이름 저장
    user.setFilepath(imageUrl); // 파일 경로(URL) 저장

    return userRepository.save(user); // 사용자 정보 업데이트
}
  • S3에 올라갈 때 사용자 Id를 파일명으로 지정하여 올리기로 결정했고
  • 클라이언트에서 사용자가 이미지를 등록했을 때 동작할 서비스 클래스 코드이다.
  • 그리고 엔티티에 파일경로를 저장하기로 해서 user 엔티티에 2개의 칼럼을 추가해준다.

 

User.java

@Entity
@Table(name = "users")
@Getter @Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username; // 이메일

    @Column(nullable = false)
    private String password;

    private String filename; // 파일 이름

    private String filepath; // 파일 경로
}

 

 

 

Controller.java

@RestController
@Slf4j
@RequiredArgsConstructor
public class S3Controller {


    private final S3Service s3Service;
    private final UserService userService;

    @PostMapping("/api/s3/image")
    public ResponseEntity<String> uploadProfileImage(
            @RequestParam("file") MultipartFile file,
            @RequestParam("userId") Long userId) throws IOException {

        User user = userService.updateProfileImage(userId, file);
        log.info("Uploaded file URL: " + user.getFilepath());
        return ResponseEntity.ok(user.getFilepath());
    }
  • 지정한 저 앤드포인트로 요청을 보내서 서버측에서 요청을 처리할 예정이다.

 

 

 

HTML & JS

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>메인 홈 입니다.</title>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <script>
            // 프로필 이미지 업로드
            $('#profile-form').on('submit', function(event) {
                event.preventDefault();

                let formData = new FormData(this);
                formData.append("userId", $('#userId').val()); // 유저 ID 추가

                $.ajax({
                    type: 'POST',
                    url: '/api/s3/image',
                    data: formData,
                    processData: false,
                    contentType: false,
                    success: function(response) {
                        alert('프로필 이미지가 성공적으로 업로드되었습니다.');
                        $('#profile-image').attr('src', response);
                    },
                    error: function(error) {
                        alert('프로필 이미지 업로드에 실패했습니다.');
                    }
                });
            });
        });
    </script>
</head>
<body>
<h2>로그인에 성공하셨어요 축하합니다.</h2>
<p>안녕하세요, 당신의 이메일 주소 :: <span th:text="${user.username}"></span>님</p>
<p>안녕하세요, 당신의 이름 :: <span th:text="${user.name}"></span></p>

<!-- 프로필 이미지 편집 섹션 -->
<h3>프로필 이미지 편집</h3>
<input type="hidden" id="userId" th:value="${user.id}"/>
<form id="profile-form">
    <input type="file" name="file" accept="image/*" required />
    <button type="submit">프로필 이미지 업로드</button>
</form>

<img id="profile-image" th:src="${user.filepath != null ? user.filepath : '/files/lioon.png'}" alt="Profile Image" style="max-width: 150px; max-height: 150px;" />

</body>
</html>

자바스크립트 파일로 지정한 앤드포인트로 요청을 보내면 S3에 업로드가 될 것이다. 

기본 사진은 미리 로컬에 지정해둔 사진으로 보여질 수 있도록 했다. 그 후 사용자가 프로필을 변경하면 변경될 수 있도록..

 

 

 

화면단에서 사진도 잘 올라가고 잘 보인다~~~ㅎㅎ

 

 

 

 

 

 

 

 

 

 

 

이렇게 이미지를 S3에 올리는 부분을 수행했다.

 

 

 

728x90
728x90

개요

저번 포스트에서 2주 체험판 weather api를 사용했는데 2주가 끝나면 3일간의 날씨가 보여줄 수가 있다고 했다.

그래서 현재 코드는 7일간의 날씨가 보이도록 했지만 무료버전으로 바뀌게 되면 오류가 발생할 것 같아서 다른 방안을 찾아 다녔다.

그러다 제일 흔한 OpenWeather API의 무료버전에서 5일간의 날씨를 보여줄 수 있다고 한다.!!! 비록 일주일은 아니지만 5일만의 날씨가 보여지는 것만으로도 만족한다..ㅎㅎㅎ

 

 

프로젝트 구성

Current weather and forecast - OpenWeatherMap

 

Current weather and forecast - OpenWeatherMap

OpenWeather Weather forecasts, nowcasts and history in a fast and elegant way

openweathermap.org

여기서 회원가입 후 My API Keys를 들어가면 기본적인 API 키가 있을 것이다. 그게 끝이다...ㅎㅎ 
그리고 가입한 이메일을 가서 인증을 하고 1시간정도 기다리면 이제 api 키가 활성화가 되어서 사용이 가능해진다.

 

 

  • 이때 발급하면 나오는 API KEY를 application.yml 파일에 저장한다.
  • 알려져서는 안되는 키이다.
weather:
  api:
    key: {API key}

 

  • 그 후에 스프링에서 날씨 api를 불러쓰기 위해서 RestTemplate를 빈으로 등록한다.
@Configuration
public class DemoApplication {

    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}
  • RestTemplate 이란?
    • HTTP 요청을 보내고 응답을 받기 위해 사용하는 클라이언트이다.
    • 외부 RESTful 웹 서비스와 통신할 때 사용된다.
    • 다른 서버의 API를 호출하여 데이터를 가져오거나, 서버로 데이터를 전송할 때 사용된다.
  • 왜 @Bean으로 등록?
    • Spring 컨텍스트 내에서 공유되며, 다른 컴포넌트에서 @Autowired를 통해 주입받아 사용할 수 있다.

 

 

날씨 API call 살펴보기

  • 그 다음으로 사이트에 들어가서  API -> {원하는 date} -> API doc 를 들어가서 url을 어떤식으로 작성해야 하는지 확인한다.
  • 하지만 무료버전/ 유료버전에 따라 다르기 때문에 잘 보고 해야한다.
  • 나는 현재날씨와 5일간의 날씨 2가지를 다 가져와서 보여지게 할 것이다.
  • 만약 현재 날씨만 가져올 것이면 Current Weather Data에 들어가서 보면 어떤 식으로 불러올지 나와있다.
  • 나는 현재 날씨는 지오코딩 api를 사용했다.

 

지오코딩은 번역기 돌려서..

 

 

 

이렇게 얘기한다 ㅎㅅㅎ지오코딩을 사용해서 현재 위치를 보여준 이유는 공식 문서에서 지오코딩 어쩌고 더 간단하다고 써봤는데... 

 

 

 

 

 

 

 

 

만약 지오코딩을 써서 현재의 날씨를 받아오면 아래와 같은 json으로 출력된다. 

[
   {
      "name":"London",
      "local_names":{
      },
      "lat":51.5073219,
      "lon":-0.1276474,
      "country":"GB",
      "state":"England"
   },

 

  • 그리고 5일간의 날씨 데이터와 3시간 간격으로 보여지도록 할 것이기에 5 Day / 3 Hour Forecast -> API doc 에 들어간다. 

  • 공식 문서에서 알려주는 듯이 api call 형태로 위도와 경도를 받아와서 보여주는 식으로 진행할 것이다. 
  • 그 다음으로 yml 저장한 키를 토대로 api 를 불러오기 위한 컨트롤러를 작성한다.
@RestController
@RequiredArgsConstructor
public class WeatherApiController {

    @Value("${weather.api.key}")
    private String apiKey;

    private final RestTemplate restTemplate;
    private final ObjectMapper objectMapper;

    @GetMapping("/api/weather")
    public ResponseEntity<String> getWeather(@RequestParam String location) {
        String geocodeUrl = String.format("http://api.openweathermap.org/geo/1.0/direct?q=%s&limit=1&appid=%s",
                location, apiKey);

        try {
            ResponseEntity<String> geocodeResponse = restTemplate.getForEntity(geocodeUrl, String.class);
            if (geocodeResponse.getStatusCode() != HttpStatus.OK || geocodeResponse.getBody() == null) {
                return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("위치 정보를 찾을 수 없습니다.");
            }

            JsonNode geocodeJsonArray = objectMapper.readTree(geocodeResponse.getBody());
            if (geocodeJsonArray.isEmpty()) {
                return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("위치 정보를 찾을 수 없습니다.");
            }

            JsonNode locationJson = geocodeJsonArray.get(0);
            double lat = locationJson.get("lat").asDouble();
            double lon = locationJson.get("lon").asDouble();

            // 5일 날씨 예보 URL
            String forecastUrl = String.format("https://api.openweathermap.org/data/2.5/forecast?lat=%s&lon=%s&appid=%s&units=metric",
                    lat, lon, apiKey);

            String forecastResponse = restTemplate.getForObject(forecastUrl, String.class);
            return ResponseEntity.ok(forecastResponse);

        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("날씨 정보를 가져오는 데 실패했습니다.");
        }
    }
}
  • 위 코드는 지오코딩으로 현재 날씨에서 json을 받아오고 응답한 뒤, 그 값에서 아래의 코드를 통해 경도와 위도를 받아온다.
            JsonNode locationJson = geocodeJsonArray.get(0);
            double lat = locationJson.get("lat").asDouble();
            double lon = locationJson.get("lon").asDouble();
  • 위에서 봤듯이 json 출력 배열의 첫 번째 요소 중 경도와 위도를 가져오는 부분이다.
  • 특정 위치의 날씨 정보를 가져오는 데 필요하다.

 

 

 

 

HTML, JS 코드

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>OMG Travel</title>
    <script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js'></script>
    <link rel="stylesheet" href="/css/header.css">
</head>
<body>
<div th:replace="fragments/header :: headerFragment"></div>
<div class="wrapper">
    <div class="container">
        <div class="left-content">
            <div class="weather-section">
                <div class="search-container">
                    <input type="text" id="location-input" class="search-input" placeholder="지역 입력">
                    <button id="search-button" class="search-button">검색</button>
                </div>
                <div>
                    <h3>현재 날씨</h3>
                    <div id="current-weather"></div>
                    <div id="current-date" style="display: none;"></div>
                    <h3>이번 주 날씨</h3>
                    <div class="weather" id="weather-container"></div>
                </div>
            </div>
        </div>
    </div>
</div>

<script>
    document.addEventListener('DOMContentLoaded', function() {
<!-- 날씨 API 불러오기 -->
        const weatherContainer = document.getElementById('weather-container');
        const currentWeather = document.getElementById('current-weather');
        const currentDateEl = document.getElementById('current-date');

        function fetchWeather(location) {
            fetch(`/api/weather?location=${encodeURIComponent(location)}`)
                .then(response => {
                    if (!response.ok) {
                        throw new Error(`HTTP 접속 오류 상태: ${response.status}`);
                    }
                    return response.json();
                })
                .then(data => {
                    // 날짜별로 날씨 정보를 집계
                    const weatherMap = {};
                    data.list.forEach(item => {
                        const date = new Date(item.dt * 1000);
                        const options = {weekday: 'short', month: 'short', day: 'numeric'};
                        const formattedDate = date.toLocaleDateString('ko-KR', options);

                        if (!weatherMap[formattedDate]) {
                            weatherMap[formattedDate] = {
                                tempSum: 0,
                                count: 0,
                                weatherDescription: item.weather[0].description,
                                iconCode: item.weather[0].icon
                            };
                        }
                        weatherMap[formattedDate].tempSum += item.main.temp;
                        weatherMap[formattedDate].count += 1;
                    });

                    // 평균 온도 계산 및 출력
                    const weatherItems = Object.keys(weatherMap).map(date => {
                        const weatherInfo = weatherMap[date];
                        const averageTemp = (weatherInfo.tempSum / weatherInfo.count).toFixed(1);
                        const iconUrl = `http://openweathermap.org/img/wn/${weatherInfo.iconCode}@2x.png`;

                        return `
                    <div class="weather-item">
                        <div class="day">${date}</div>
                        <div class="temp">${averageTemp}°C</div>
                        <img src="${iconUrl}" alt="날씨 아이콘" class="weather-icon"/>
                        <div>${weatherInfo.weatherDescription}</div>
                    </div>
                `;
                    }).join('');

                    weatherContainer.innerHTML = weatherItems;

                    const today = new Date();
                    const options = {year: 'numeric', month: 'long', day: 'numeric'};
                    const formattedDate = today.toLocaleDateString('ko-KR', options);

                    // 현재 날씨를 보여주는 부분
                    const currentItem = data.list[0];
                    currentWeather.innerHTML = `
                <div class="current-temp">${currentItem.main.temp}°C</div>
                <div class="current-description">${currentItem.weather[0].description}</div>
                <img src="http://openweathermap.org/img/wn/${currentItem.weather[0].icon}@2x.png" alt="날씨 아이콘" class="weather-icon"/>
            `;
                    currentDateEl.innerHTML = `<div>${formattedDate}</div>`;
                })
                .catch(error => console.error('날씨 정보를 가져오지 못했습니다:', error));
        }

        fetchWeather('Seoul'); // 기본값 서울

        document.getElementById('search-button').addEventListener('click', function () {
            const locationInput = document.getElementById('location-input').value;
            if (locationInput) {
                fetchWeather(locationInput);
            } else {
                alert('지역 이름을 입력하세요.');
            }
        });

        document.getElementById('location-input').addEventListener('keypress', function (e) {
            if (e.key === 'Enter') {
                document.getElementById('search-button').click();
            }
        });
    });
</script>
</body>
</html>


위와 같이 코드를 작성하면 화면단에 날씨가 보인다!!

참고로 왜 5일간의 데이터가 보인다고 했는데 실제로 보이는건 오늘 날짜를 포함한 6일의 날씨가 보인다. 이건 

open weather API에서 제공하는 자체가 5일간의 데이터 + 3시간 후 날씨 여서 현재 날씨의 3시간 후 날씨도 같이 보여지는 것이다. 보여지는게 싫다면 자바 코드에서 수정하면 되지만.. 굳이!? 

화면단 구성 완료..... 휴 힘들었다 ㅜ.ㅜ

 

728x90

+ Recent posts